Lesson 30 of 73
Subcommands, Configuration, and Packaging
The course builds a command-line tool called loop. It downloads models, starts a local model server, chats with a model, and runs a coding agent. A tool with that many jobs cannot be one command with a long list of options. It needs several commands, such as loop pull and loop serve, in the same way that git has git commit and git push.
picocli Basics (picocli is the Java library that turns annotated classes into a command-line program) built single commands and the first loop skeleton. This lesson covers what a tool with many commands needs on top of that. It needs command trees, options shared between commands, default values from the environment and from a configuration file, secrets typed at a prompt, colors that respect the terminal, output that scripts can read, helpful error messages, and Tab completion in the shell.
The last three examples cover shipping. A Java program normally needs a Java installation on the user's machine, and many users do not have one. After this lesson, a reader can structure loop the way it grows in the rest of the course and package it as a jar, a self-contained runtime, an installer, or a native executable.
The table lists each topic in plain words, the picocli feature or tool that handles it, and the example that shows it.
| Topic | API or tool | Example |
|---|---|---|
| Command trees | subcommands, @ParentCommand |
1 |
| Options for every subcommand | scope = ScopeType.INHERIT |
2 |
| Shared option groups | @Mixin |
3 |
| Defaults from the environment | ${env:...}, ${sys:...}, ${NAME:-default} |
4 |
| Defaults from a file | IDefaultValueProvider |
5 |
| Secrets | interactive, prompt |
6 |
| Colors | Help.Ansi, NO_COLOR |
7 |
| Output for people and scripts | --json, fixed-width tables |
8 |
| Error messages and suggestions | IParameterExceptionHandler |
9 |
| Shell completion | AutoComplete |
10 |
| Packaging | Maven Shade, jlink, jpackage, native-image |
11 to 13 |
Examples 1 to 10 are complete Java programs. Each one runs with a fixed list of arguments written inside main, as in the previous lesson, so the output is the same on every run. Examples 11 to 13 are shell commands and build configuration instead of programs. Their output was captured on the machine that builds this course (JDK 25, Maven 3, picocli 4.7.7). File sizes and paths differ on other machines.
Example 1: subcommand trees and @ParentCommand
A subcommand is a command that is named after another command. In git remote add, remote is a subcommand of git, and add is a subcommand of remote. This gives the tool a tree of commands: the root is the program name, and each branch is a word the user types.
Two questions follow. How does picocli build such a tree? And how does a command at the bottom of the tree read an option that was typed for the command above it? Each level lists its children in the subcommands attribute. A field annotated with @ParentCommand receives the object of the parent command, so the child can read the parent's fields.
The program below builds loop with two children, config and pull. config has two children of its own, get and set. It prints the tree, then runs four command lines. Look for where the --file option works and where it is rejected.
import java.io.PrintWriter;
import java.util.Map;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
@Command(name = "loop", subcommands = {SubcommandTree.Config.class, SubcommandTree.Pull.class})
public class SubcommandTree {
@Command(name = "config", description = "Read and write settings.",
subcommands = {Config.Get.class, Config.Set.class})
static class Config { // no run(): a subcommand is required
@Option(names = "--file", defaultValue = "config.json") String file;
@Command(name = "get", description = "Print one setting.")
static class Get implements Runnable {
@ParentCommand Config parent; // the Config object picocli created
@Parameters(paramLabel = "KEY") String key;
public void run() { System.out.println("get " + key + " from " + parent.file); }
}
@Command(name = "set", description = "Change one setting.")
static class Set implements Runnable {
@ParentCommand Config parent;
@Parameters(index = "0", paramLabel = "KEY") String key;
@Parameters(index = "1", paramLabel = "VALUE") String value;
public void run() { System.out.println("set " + key + " = " + value + " in " + parent.file); }
}
}
@Command(name = "pull", description = "Download a model.")
static class Pull implements Runnable {
@Parameters(paramLabel = "MODEL") String model;
public void run() { System.out.println("pull " + model); }
}
static void printTree(CommandLine cl, String indent) {
for (Map.Entry<String, CommandLine> e : cl.getSubcommands().entrySet()) {
System.out.println(indent + e.getKey() + " (" + e.getValue().getCommandSpec().qualifiedName() + ")");
printTree(e.getValue(), indent + " ");
}
}
public static void main(String[] args) {
printTree(new CommandLine(new SubcommandTree()), "");
String[][] runs = {{"config", "get", "server.port"}, {"config", "--file", "work.json", "set", "server.port", "9000"},
{"config", "set", "--file", "work.json", "server.port", "9000"}, {"config"}};
for (String[] a : runs) {
CommandLine cl = new CommandLine(new SubcommandTree());
cl.setErr(new PrintWriter(System.out, true));
cl.setParameterExceptionHandler((ex, x) -> { ex.getCommandLine().getErr().println(ex.getMessage()); return 2; });
System.out.println("$ loop " + String.join(" ", a));
System.out.println("exit code " + cl.execute(a));
}
}
}config (loop config)
get (loop config get)
set (loop config set)
pull (loop pull)
$ loop config get server.port
get server.port from config.json
exit code 0
$ loop config --file work.json set server.port 9000
set server.port = 9000 in work.json
exit code 0
$ loop config set --file work.json server.port 9000
Unknown options: '--file', '9000'
exit code 2
$ loop config
Missing required subcommand
exit code 2The first four lines of output show the tree. The program walks it with getSubcommands(), which returns a map from each subcommand name to that subcommand's own CommandLine object. qualifiedName() is the full path of a command, which is the text a user types to reach it. The indentation shows that loop has two children, config and pull, and that config has two children, get and set.
The four runs show how options and arguments are matched to levels of the tree:
loop config get server.portselectsconfig, thenget. picocli creates one object per level for this parse and setsGet.parentto theConfigobject, sogetreadsparent.file, which holds its default,config.json.loop config --file work.json set server.port 9000gives--filetoconfig, the level where it is declared, before namingset.setseeswork.jsonthrough its parent.loop config set --file work.json server.port 9000puts--fileafterset. An option belongs to the level that declares it, and--fileis declared onconfig, not onset. Sosetdoes not know the option, and picocli reports it as unknown. The positional arguments then shift.setexpects exactly two (KEYandVALUE), sowork.jsonbecomesKEYandserver.portbecomesVALUE. The last argument,9000, has nowhere to go. This is why the message lists both--fileand9000.loop configwith no further argument fails withMissing required subcommand, becauseConfigimplements neitherRunnablenorCallable. A command class without a method to run must be followed by one of its subcommands.
@ParentCommand reaches one level up. A command deeper in the tree reaches higher levels through parent.parent, or through the CommandLine objects with spec.commandLine().getParent(). The rule that an option must be typed at the level that declares it is strict, and it can annoy users. The next example removes that restriction for options that every command needs.
Example 2: inherited options
Some options make sense for every command. --verbose asks for more output. --home names the directory where loop keeps its models and settings. Declaring them in every command class would repeat the same code many times, and users would expect to type them in either position, before or after the subcommand name.
picocli solves this with the scope of an option. The default scope, ScopeType.LOCAL, means the option belongs to one command only, which is what Example 1 showed. scope = ScopeType.INHERIT copies the option into every subcommand below the command that declares it.
The program declares both options once on the top-level command and runs five command lines. The last lines print the help of list. Watch where the option values end up, and whether the position of the option on the line matters.
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.ScopeType;
@Command(name = "loop", subcommands = {InheritedOptions.Pull.class, InheritedOptions.ListModels.class})
public class InheritedOptions {
@Option(names = {"-v", "--verbose"}, scope = ScopeType.INHERIT, description = "More output (repeatable)")
boolean[] verbose = new boolean[0];
@Option(names = "--home", scope = ScopeType.INHERIT, defaultValue = ".loop", description = "Data directory")
String home;
@Command(name = "pull")
static class Pull implements Callable<Integer> {
@ParentCommand InheritedOptions loop;
@Parameters(paramLabel = "MODEL") String model;
public Integer call() {
System.out.println("pull " + model + ": home=" + loop.home + " verbosity=" + loop.verbose.length);
return 0;
}
}
@Command(name = "list")
static class ListModels implements Callable<Integer> {
@ParentCommand InheritedOptions loop;
public Integer call() {
System.out.println("list: home=" + loop.home + " verbosity=" + loop.verbose.length);
return 0;
}
}
public static void main(String[] args) {
String[][] runs = {{"pull", "org/model"}, {"--home", "/srv/loop", "pull", "org/model"},
{"pull", "org/model", "--home", "/srv/loop"}, {"list", "-vv"}, {"-v", "list", "-vv"}};
for (String[] a : runs) {
System.out.printf("%-42s -> ", "loop " + String.join(" ", a));
new CommandLine(new InheritedOptions()).execute(a);
}
System.out.print(new CommandLine(new InheritedOptions()).getSubcommands().get("list").getUsageMessage());
}
}loop pull org/model -> pull org/model: home=.loop verbosity=0
loop --home /srv/loop pull org/model -> pull org/model: home=/srv/loop verbosity=0
loop pull org/model --home /srv/loop -> pull org/model: home=/srv/loop verbosity=0
loop list -vv -> list: home=.loop verbosity=2
loop -v list -vv -> list: home=.loop verbosity=2
Usage: loop list [-v] [--home=<home>]
--home=<home> Data directory
-v, --verbose More output (repeatable)In the program, verbose is a boolean[] (an array of booleans). picocli adds one element for each time the flag appears, so -vv produces an array of length 2. The program prints loop.verbose.length as the verbosity level. The constant is named ScopeType.INHERIT in picocli 4.7.7. The other value, LOCAL, is the default.
The runs show these results:
loop pull org/modeluses the default home,.loop, and verbosity 0.loop --home /srv/loop pull org/modelsets the option on the top-level command, where it is declared.loop pull org/model --home /srv/loopsets it after the subcommand. Example 1 rejected that order for a local option; for an inherited option picocli accepts it, and the value lands in the field of the top-level object.pullreads it there through@ParentCommand, so the command code does not care where the user typed the option.loop list -vvsets verbosity 2 from the subcommand's position.loop -v list -vvgives the option at both levels. The result is 2, not 3. The later occurrence replaces the earlier value instead of adding to it. A tool that wants-vcounted across levels has to add the counts itself.
The last three lines are the usage text of list. They show the copied options, [-v] [--home=<home>], although the ListModels class declares none. Users see the shared options in the help of every command, wherever they type --help.
Example 3: mixins for shared option groups
Inherited options go to every subcommand. Some options belong to only a few commands. loop serve, loop chat, and loop agent all need to know a server address and an API key. The commands pull and rm never talk to a server, so these options would only confuse their help text.
The question is how to declare a group of options once and add it to selected commands only. A mixin is a plain class whose options are added to any command that declares a field of that class annotated with @Mixin.
The program below defines ServerOptions with three options (--host, --port, --api-key) and a helper method that builds a URL. Three commands use it. The program runs three of them and prints the help of agent. Check which options appear in that help.
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
@Command(name = "loop", subcommands = {MixinOptions.Serve.class, MixinOptions.Chat.class, MixinOptions.Agent.class})
public class MixinOptions {
/** Options for talking to an OpenAI-compatible server; reused by three commands. */
static class ServerOptions {
@Option(names = "--host", defaultValue = "127.0.0.1", description = "Server address") String host;
@Option(names = "--port", defaultValue = "8080", description = "Server port") int port;
@Option(names = "--api-key", description = "Key sent as a Bearer token") String apiKey;
String baseUrl() { return "http://" + host + ":" + port + "/v1"; }
}
@Command(name = "serve", description = "Start an engine.")
static class Serve implements Runnable {
@Mixin ServerOptions server;
@Option(names = "--model", required = true) String model;
public void run() { System.out.println("serve " + model + " on " + server.host + ":" + server.port); }
}
@Command(name = "chat", description = "Chat with a server.")
static class Chat implements Runnable {
@Mixin ServerOptions server;
public void run() { System.out.println("chat via " + server.baseUrl() + (server.apiKey == null ? "" : " with a key")); }
}
@Command(name = "agent", description = "Run the coding agent.")
static class Agent implements Runnable {
@Mixin ServerOptions server;
@Option(names = "--max-steps", defaultValue = "30", description = "Step limit") int maxSteps;
public void run() { System.out.println("agent via " + server.baseUrl() + ", at most " + maxSteps + " steps"); }
}
public static void main(String[] args) {
new CommandLine(new MixinOptions()).execute("serve", "--model", "tiny.gguf", "--port", "8081");
new CommandLine(new MixinOptions()).execute("chat", "--port", "8081", "--api-key", "secret");
new CommandLine(new MixinOptions()).execute("agent", "--host", "10.0.0.5");
System.out.print(new CommandLine(new MixinOptions()).getSubcommands().get("agent").getUsageMessage());
}
}serve tiny.gguf on 127.0.0.1:8081
chat via http://127.0.0.1:8081/v1 with a key
agent via http://10.0.0.5:8080/v1, at most 30 steps
Usage: loop agent [--api-key=<apiKey>] [--host=<host>] [--max-steps=<maxSteps>]
[--port=<port>]
Run the coding agent.
--api-key=<apiKey> Key sent as a Bearer token
--host=<host> Server address
--max-steps=<maxSteps>
Step limit
--port=<port> Server portServerOptions is an ordinary class with three options and a helper method. Each command that declares @Mixin ServerOptions server gets all three options. picocli creates a separate ServerOptions object for each command and fills it from that command's arguments.
The output lines show the result:
servereadsserver.hostandserver.port;--port 8081overrides the default 8080.chatcallsserver.baseUrl(), which buildshttp://127.0.0.1:8081/v1from the two options. An OpenAI-compatible server, which a later lesson on llama.cpp describes, serves its API under/v1. The API key was given, so the line ends withwith a key.agentchanges only the host; the port stays 8080.- The usage of
agentlists its own--max-stepstogether with the three mixed-in options, sorted alphabetically as one list.
The difference from inherited options is where the options appear. A mixin adds its options only to the commands that declare it, and its values live in each command's own object. A mixin can also carry methods, as baseUrl() shows, so the code that interprets the options is written once. mixinStandardHelpOptions from the previous lesson is a built-in mixin of this kind. It adds --help and --version to a command.
Example 4: default values from environment variables and system properties
Typing every setting on every run is tedious. Users and installers often set values once in the environment instead. An environment variable is a named value that the operating system passes to every program, such as LOOP_HOME for the data directory or HF_ENDPOINT for a mirror of the Hugging Face Hub (The Hugging Face Hub, Example 11). A system property is the Java equivalent: a named value set with java -Dname=value or with System.setProperty.
The question is how an option's default can come from these sources. picocli replaces variables written as ${...} inside defaultValue strings at the moment it applies the default.
The program declares three options whose defaults use this syntax, then runs loop three times: with nothing set, with system properties set, and with command-line values. The last line prints a line of the help text.
import java.nio.file.Path;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "loop")
public class EnvDefaults implements Runnable {
@Option(names = "--home", defaultValue = "${sys:loop.home:-${env:LOOP_HOME:-.loop}}",
description = "Data directory (default: ${DEFAULT-VALUE})")
Path home;
@Option(names = "--hub", defaultValue = "${HF_ENDPOINT:-https://huggingface.co}", description = "Hub URL")
String hub;
@Option(names = "--threads", defaultValue = "${sys:loop.threads:-4}", description = "CPU threads")
int threads;
public void run() { System.out.println("home=" + home + " hub=" + hub + " threads=" + threads); }
public static void main(String[] args) {
// Assumes LOOP_HOME and HF_ENDPOINT are not set in the environment.
new CommandLine(new EnvDefaults()).execute();
System.setProperty("loop.home", "/srv/loop"); // like: java -Dloop.home=/srv/loop ...
System.setProperty("loop.threads", "16");
System.setProperty("HF_ENDPOINT", "http://127.0.0.1:9999");
new CommandLine(new EnvDefaults()).execute();
new CommandLine(new EnvDefaults()).execute("--home", "/tmp/other", "--threads", "2");
System.out.println(new CommandLine(new EnvDefaults()).getUsageMessage().lines()
.filter(l -> l.contains("--home")).skip(1).findFirst().orElseThrow());
}
}home=.loop hub=https://huggingface.co threads=4
home=/srv/loop hub=http://127.0.0.1:9999 threads=16
home=/tmp/other hub=http://127.0.0.1:9999 threads=2
--home=<home> Data directory (default: /srv/loop)picocli supports these variable forms in annotation strings:
| Form | Value |
|---|---|
${sys:NAME} |
System property NAME (set with java -DNAME=...) |
${env:NAME} |
Environment variable NAME |
${NAME} |
System property NAME, else environment variable NAME, else a resource bundle key |
${...:-fallback} |
fallback when the variable is not defined |
${DEFAULT-VALUE} |
The option's resolved default, in descriptions |
The forms nest, so ${sys:loop.home:-${env:LOOP_HOME:-.loop}} is read from the inside out. It means: the system property loop.home if it is set, else the environment variable LOOP_HOME if it is set, else the text .loop.
The program assumes LOOP_HOME and HF_ENDPOINT are not set in the environment, which is true where the course's examples are checked. The output lines show these results:
- The first line uses every fallback:
.loop,https://huggingface.co, and 4 threads. - The program then sets three system properties, as
java -Dloop.home=/srv/loopwould. The second line shows each of them taking effect.HF_ENDPOINTis found as a system property by the plain${HF_ENDPOINT:-...}form, which looks there first. - The third line passes
--homeand--threadson the command line, and those win over every default.--hubwas not given, so it keeps the value from the system property. - The last line is the help text, generated after the properties were set.
${DEFAULT-VALUE}shows/srv/loop, the value that would be used now, so the help tells the user the effective default rather than the expression.
The order of precedence for --home in loop is therefore: the command line first, then the system property, then the environment variable, then the built-in value. The first source that has a value wins. Example 5 adds a configuration file and shows where picocli places it in that chain.
Example 5: a default-value provider that reads ~/.loop/config.json
Settings that a user changes once, such as a preferred port or a context size (the amount of text a model can read at once), belong in a file, not in a long command line. The question is how option defaults can come from a JSON file. picocli has a hook for this. An IDefaultValueProvider is a class with one method that picocli calls for each option to ask "what is the default for this one?". If the provider has no answer, picocli falls back to the annotation's defaultValue.
The program defines a provider that reads a file shaped like {"serve": {"port": 9000}}. The top-level key is a command name, and the key below it is an option name without the dashes. It then runs loop serve three times: before the file exists, after the file is written, and with --port on the command line.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.IDefaultValueProvider;
import picocli.CommandLine.Model.ArgSpec;
import picocli.CommandLine.Model.OptionSpec;
import picocli.CommandLine.Option;
@Command(name = "loop", subcommands = {ConfigFileDefaults.Serve.class})
public class ConfigFileDefaults {
/** Reads defaults from {"serve": {"port": 9000, ...}}: section = command name, key = longest option name. */
static class JsonConfigDefaults implements IDefaultValueProvider {
private final JsonNode root;
JsonConfigDefaults(Path file) throws Exception {
root = Files.exists(file) ? new ObjectMapper().readTree(file.toFile()) : new ObjectMapper().createObjectNode();
}
@Override
public String defaultValue(ArgSpec arg) {
if (!(arg instanceof OptionSpec option)) return null; // positional parameters: no file default
String section = arg.command().name();
String key = option.longestName().replaceFirst("^-+", "");
JsonNode value = root.path(section).path(key);
return value.isValueNode() ? value.asText() : null; // null: use the annotation's default
}
}
@Command(name = "serve")
static class Serve implements Runnable {
@Option(names = "--port", defaultValue = "8080") int port;
@Option(names = {"-c", "--ctx-size"}, defaultValue = "4096") int ctx;
@Option(names = "--flash-attn", defaultValue = "auto") String flashAttn;
public void run() { System.out.println("port=" + port + " ctx=" + ctx + " flash-attn=" + flashAttn); }
}
public static void main(String[] args) throws Exception {
Path home = Files.createTempDirectory("loop-home");
Path config = home.resolve("config.json");
new CommandLine(new ConfigFileDefaults()).setDefaultValueProvider(new JsonConfigDefaults(config)).execute("serve");
Files.writeString(config, """
{"serve": {"port": 9000, "ctx-size": 16384}, "pull": {"quant": "Q4_K_M"}}
""");
CommandLine cl = new CommandLine(new ConfigFileDefaults()).setDefaultValueProvider(new JsonConfigDefaults(config));
cl.execute("serve");
new CommandLine(new ConfigFileDefaults()).setDefaultValueProvider(new JsonConfigDefaults(config)).execute("serve", "--port", "9100");
}
}port=8080 ctx=4096 flash-attn=auto
port=9000 ctx=16384 flash-attn=auto
port=9100 ctx=16384 flash-attn=autoThe provider maps an option to a path in the JSON. The section is the command's name (serve), and the key is the option's longest name without dashes (port, ctx-size). It returns the value as text, because picocli converts default strings with the option's converter, as it does for text typed on the command line. When the file has no such key, the provider returns null, which means "no answer", and picocli uses the annotation's defaultValue.
The order matters when both sources exist. picocli asks the provider first. It evaluates the annotation's defaultValue, including any ${env:...} or ${sys:...} expression in it, only when the provider returns null. A value in the file therefore wins over an environment variable or system property named in the annotation. A test with defaultValue = "${sys:p:-1}", the property p set to 2, and a provider returning 3 gave 3. A tool that wants environment variables to win over the file checks them inside its provider before it reads the file.
The three runs show the effect:
- The first run happens before the file exists. The constructor creates an empty JSON object, every lookup returns
null, and the annotation defaults apply: port 8080, context 4096, flash attentionauto. - The program then writes
config.jsonwith aservesection. The second run reads port 9000 and context 16,384 from the file.flash-attnis not in the file, so it keeps its annotation default. Thepullsection is ignored byserve. - The third run passes
--port 9100. Command-line values always win over any default, so the port is 9100 while the context still comes from the file.
setDefaultValueProvider on the top-level CommandLine also applies to its subcommands, which is why serve used it. The real loop builds the file path from --home (Example 2) and reads config.json there. picocli also ships PropertiesDefaultProvider, which reads a .properties file; the JSON version here matches the other files loop writes, such as the model manifests of the workshop lesson.
Example 6: prompting for a token without echo
loop pull needs a Hugging Face access token to download gated models, which are models whose authors require users to log in first (The Hub HTTP API and Cache Layout in Java, Example 8). A token is a secret, like a password. If the user types it as a command-line argument, two things go wrong. The shell saves the line in its history file, and other users on the machine can see the arguments in the list of running processes.
The question is how to ask for the secret at a prompt instead, without showing the characters on screen. An option with interactive = true makes picocli prompt for the value.
The program has one option, --token, and runs three times: with --token alone, with --token and a value, and without the option. Because a test program has no person to type, main replaces standard input with a fixed text. Look for the prompt text and for the masked token in the output.
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "login")
public class TokenPrompt implements Callable<Integer> {
@Option(names = "--token", interactive = true, arity = "0..1", prompt = "Hugging Face token: ",
description = "Access token; prompts when given without a value")
char[] token;
@Override
public Integer call() {
if (token == null) {
System.out.println("no token given");
return 1;
}
String masked = new String(token, 0, 3) + "*".repeat(token.length - 3);
System.out.println("received " + token.length + " characters: " + masked);
Arrays.fill(token, '\0'); // overwrite the secret after use
return 0;
}
public static void main(String[] args) {
// Simulates a user typing a token; with a real terminal, picocli reads it without echo.
System.setIn(new ByteArrayInputStream("hf_AbCdEf123456\n".getBytes(StandardCharsets.UTF_8)));
System.out.println("exit code " + new CommandLine(new TokenPrompt()).execute("--token"));
System.out.println("exit code " + new CommandLine(new TokenPrompt()).execute("--token", "hf_FromArgs99"));
System.out.println("exit code " + new CommandLine(new TokenPrompt()).execute());
}
}Hugging Face token: received 15 characters: hf_************
exit code 0
received 13 characters: hf_**********
exit code 0
no token given
exit code 1interactive = true with arity = "0..1" gives the option two modes. When --token is followed by a value, that value is used. When it appears alone, picocli prints the prompt text and reads one line. The field type is char[] rather than String, so the program can overwrite the characters with Arrays.fill after use; a String cannot be changed and stays in memory until the garbage collector removes it.
Where the line comes from depends on the environment. With a terminal attached, picocli reads through System.console(). The echo attribute of an interactive option is false by default, so the typed characters do not appear on the screen. Without a terminal, as here, picocli reads from System.in. The program replaces System.in with the text hf_AbCdEf123456 and a newline to simulate typing, which makes the example runnable without a person.
The output has three parts:
Hugging Face token:is the prompt. The program's own line follows on the same line because a prompt ends without a newline, leaving the cursor after it for typing. The program prints 15 characters, showing only thehf_prefix and masking the rest.--token hf_FromArgs99takes the value from the arguments, 13 characters, with no prompt.- With no
--tokenat all, the field staysnulland the command returns 1.
There is a third way to supply a token, which huggingface_hub (the Python library for the Hub) and llama.cpp (a C++ program that runs models) also use: the HF_TOKEN environment variable. loop checks --token first, then HF_TOKEN, then a token file in its home directory. It prompts only when the user gives --token without a value.
Example 7: ANSI colors, detection, and NO_COLOR
Colored output helps a reader scan a terminal. Terminals produce colors from ANSI escape codes, which are short invisible character sequences that start with the escape character (written \e in this lesson) and tell the terminal "switch to green now". In a log file or in the input of another program, those codes are noise, because the reader sees raw characters such as [32m.
The question is how picocli decides whether to emit colors, and how a tool lets the user override that decision. picocli's Help.Ansi has three values: ON, OFF, and AUTO, which decides at run time.
The program first prints what AUTO decides under three settings. Then it runs a command with its own --color option in all three modes. Finally it prints the first line of the help text in color. The program replaces the escape character with the text \e so the codes are visible in the output.
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Help;
import picocli.CommandLine.Help.Ansi;
import picocli.CommandLine.Option;
@Command(name = "pull")
public class AnsiColors implements Runnable {
enum ColorMode { auto, always, never }
@Option(names = "--color", defaultValue = "auto", description = "auto, always, or never") ColorMode color;
Ansi ansi() {
return switch (color) { case always -> Ansi.ON; case never -> Ansi.OFF; case auto -> Ansi.AUTO; };
}
public void run() {
String line = ansi().string("@|bold,green done|@ pulled @|yellow Q4_K_M|@");
System.out.println(color + ": " + line.replace("\u001B", "\\e"));
}
public static void main(String[] args) {
System.out.println("AUTO with output to a pipe: " + Ansi.AUTO.enabled());
System.setProperty("picocli.ansi", "true");
System.out.println("AUTO with -Dpicocli.ansi=true: " + Ansi.AUTO.enabled());
System.setProperty("picocli.ansi", "false");
System.out.println("AUTO with -Dpicocli.ansi=false: " + Ansi.AUTO.enabled());
System.clearProperty("picocli.ansi");
for (String mode : new String[] {"auto", "always", "never"}) new CommandLine(new AnsiColors()).execute("--color", mode);
CommandLine cl = new CommandLine(new AnsiColors());
cl.setColorScheme(Help.defaultColorScheme(Ansi.ON)); // usage help in color too
System.out.println(cl.getUsageMessage().lines().findFirst().orElseThrow().replace("\u001B", "\\e"));
}
}AUTO with output to a pipe: false
AUTO with -Dpicocli.ansi=true: true
AUTO with -Dpicocli.ansi=false: false
auto: done pulled Q4_K_M
always: \e[1m\e[32mdone\e[39m\e[21m\e[0m pulled \e[33mQ4_K_M\e[39m\e[0m
never: done pulled Q4_K_M
Usage: \e[1mpull\e[21m\e[0m [\e[33m--color\e[39m\e[0m=\e[3m<color>\e[23m\e[0m]The first three lines show Ansi.AUTO.enabled() under different settings. The program's output goes to a pipe when the course checks it, so AUTO finds no terminal and answers false. The system property picocli.ansi overrides detection: true forces colors on and false forces them off. Clearing the property restores detection.
picocli 4.7.7 applies these rules for AUTO, in order. They were read from its Help.Ansi class:
- The system property
picocli.ansi, when set totrueorfalse, decides;ttymeans "use terminal detection only". - If the environment variable
NO_COLORis set, to any value, colors are off.NO_COLORis a convention that many command-line tools follow, so a user who dislikes colors sets it once in the shell profile and every tool obeys. - If
CLICOLOR_FORCEis set to anything other than0, colors are on, even into a pipe. - If
CLICOLORis0, colors are off. - Otherwise colors are on when
System.console()exists and, on Java 22 and later,Console.isTerminal()returnstrue; picocli also recognizes some Windows terminals.
The next three lines come from the command's own --color option. It is an enum (a type with a fixed list of values) with the values auto, always, and never, the same names that ls and git use. The method ansi() maps them to AUTO, ON, and OFF. Ansi.string turns picocli markup such as @|bold,green done|@ into text with escape codes, or into plain text when colors are off:
autoprints plain text here, becauseAUTOis off in a pipe.alwaysemits escape codes, shown with\e:\e[1m(bold) and\e[32m(green) beforedone,\e[39m(default color) and\e[21m(end bold) after it, and\e[33m(yellow) aroundQ4_K_M.neverprints plain text in every environment.
The last line shows that the usage help follows a separate setting. setColorScheme(Help.defaultColorScheme(Ansi.ON)) makes picocli color the command name bold, the option names yellow, and the labels italic (\e[3m). loop passes the same Ansi value to both its own output and the help text, so --color never and NO_COLOR turn off all colors at once.
Example 8: output for scripts and fixed-width tables for people
loop list shows the models on disk. Two kinds of readers use it. A person in a terminal wants a neat table with sizes such as 4.36 GiB. A script wants data it can parse reliably, with exact numbers and no decoration. The question is how one command serves both.
The approach is two output modes. By default the command prints a fixed-width table, where every row uses the same column widths so the columns line up. With --json it prints the same data as JSON (a text format for structured data) with raw values, such as the exact number of bytes.
The program holds three model entries in a list and prints them in both modes. Compare the size column of the table with the sizeBytes field in the JSON.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Locale;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@Command(name = "list")
public class OutputFormats implements Runnable {
record ModelEntry(String name, String quant, long sizeBytes, String modified) {}
static final List<ModelEntry> MODELS = List.of(
new ModelEntry("example-org/Coder-7B-Instruct-GGUF", "Q4_K_M", 4_683_073_984L, "2026-09-30"),
new ModelEntry("example-org/Coder-1.5B-GGUF", "Q8_0", 1_646_572_992L, "2026-10-01"),
new ModelEntry("tiny/random-qwen2vocab", "F32", 322_454_464L, "2026-10-02"));
@Option(names = "--json", description = "Print JSON for scripts") boolean json;
static String human(long bytes) {
return bytes >= 1L << 30 ? String.format(Locale.ROOT, "%.2f GiB", bytes / (double) (1L << 30))
: String.format(Locale.ROOT, "%.1f MiB", bytes / (double) (1L << 20));
}
public void run() {
if (json) {
try {
System.out.println(new ObjectMapper().writeValueAsString(MODELS));
} catch (Exception e) {
throw new IllegalStateException(e);
}
return;
}
String format = "%-36s %-7s %10s %s%n";
System.out.printf(format, "NAME", "QUANT", "SIZE", "MODIFIED");
for (ModelEntry m : MODELS) System.out.printf(format, m.name(), m.quant(), human(m.sizeBytes()), m.modified());
}
public static void main(String[] args) {
new CommandLine(new OutputFormats()).execute();
new CommandLine(new OutputFormats()).execute("--json");
}
}NAME QUANT SIZE MODIFIED
example-org/Coder-7B-Instruct-GGUF Q4_K_M 4.36 GiB 2026-09-30
example-org/Coder-1.5B-GGUF Q8_0 1.53 GiB 2026-10-01
tiny/random-qwen2vocab F32 307.5 MiB 2026-10-02
[{"name":"example-org/Coder-7B-Instruct-GGUF","quant":"Q4_K_M","sizeBytes":4683073984,"modified":"2026-09-30"},{"name":"example-org/Coder-1.5B-GGUF","quant":"Q8_0","sizeBytes":1646572992,"modified":"2026-10-01"},{"name":"tiny/random-qwen2vocab","quant":"F32","sizeBytes":322454464,"modified":"2026-10-02"}]The table uses one format string, %-36s %-7s %10s %s%n, for the header and for every row. Each part of it has a job:
%-36sleft-aligns the name in 36 columns, enough for the longest name here (34 characters) plus space. A name longer than the width would push the following columns to the right; a real implementation computes the width from the longest name.%10sright-aligns the size, so the decimal points of4.36 GiBand1.53 GiBline up and sizes can be compared by position.human()prints GiB with two decimals at or above 1 GiB and MiB with one decimal below it. 4,683,073,984 bytes is 4.36 GiB and 322,454,464 bytes is 307.5 MiB; both useLocale.ROOT, so the decimal separator is a period in every locale.
The JSON line is one array with one object per model. Jackson (the Java JSON library) writes each component of the record as a field, in declaration order. sizeBytes is the exact integer, not the rounded text, so a script can compare it directly without parsing units such as GiB. For example, this command with jq (a command-line JSON tool) prints the names of models larger than 2 GB:
loop list --json | jq -r '.[] | select(.sizeBytes > 2000000000) | .name'The separation follows a common rule for command-line tools. Human output may change between versions, for example with new columns or different rounding. The JSON field names are a stable interface that scripts can depend on. Errors go to standard error (the second output stream, separate from normal output), and the table goes to standard output. So loop list > models.txt captures only the table, and error messages still appear on the screen.
Example 9: unmatched arguments and suggestions for mistyped subcommands
Typing mistakes are the most common failure of a command-line tool. A user types loop serv instead of loop serve, or --revison instead of --revision. By default, picocli prints the error and then the full help text of the command, which can fill a screen and hide the error.
The question is what picocli reports for each kind of mistake and how to make the message short and helpful. An IParameterExceptionHandler is a function that picocli calls when the user's input is invalid. The handler in this example prints one error line, then picocli's suggestions if it has any, then a hint about where to find help.
The program runs six command lines with mistakes and prints the result of each. Look at which mistakes get a "Did you mean" suggestion and which do not.
import java.io.PrintWriter;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.IParameterExceptionHandler;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.UnmatchedArgumentException;
@Command(name = "loop", subcommands = {ErrorMessages.Pull.class, ErrorMessages.Serve.class, ErrorMessages.ListModels.class})
public class ErrorMessages {
@Command(name = "pull") static class Pull implements Runnable {
@Parameters(paramLabel = "MODEL") String model;
@Option(names = "--quant") String quant;
@Option(names = "--revision") String revision;
public void run() { System.out.println("pull " + model + " " + quant); }
}
@Command(name = "serve") static class Serve implements Runnable { public void run() { } }
@Command(name = "list") static class ListModels implements Runnable { public void run() { } }
static final IParameterExceptionHandler SHORT_ERRORS = (ex, args) -> {
CommandLine cmd = ex.getCommandLine(); // the (sub)command that failed
PrintWriter err = cmd.getErr();
err.println("loop: " + ex.getMessage());
UnmatchedArgumentException.printSuggestions(ex, err); // prints nothing when there is no close match
err.println("Run '" + cmd.getCommandSpec().qualifiedName() + " --help' for usage.");
return cmd.getCommandSpec().exitCodeOnInvalidInput();
};
public static void main(String[] args) {
String[][] runs = {{"serv"}, {"lst"}, {"pull", "org/model", "--revison", "v1"}, {"pull", "org/model", "--qaunt", "Q8_0"},
{"pull", "org/model", "--quant"}, {"download", "org/model"}};
for (String[] a : runs) {
CommandLine cl = new CommandLine(new ErrorMessages());
cl.setErr(new PrintWriter(System.out, true));
cl.setParameterExceptionHandler(SHORT_ERRORS);
System.out.println("$ loop " + String.join(" ", a));
System.out.println("exit code " + cl.execute(a));
}
}
}$ loop serv
loop: Unmatched argument at index 0: 'serv'
Did you mean: loop serve?
Run 'loop --help' for usage.
exit code 2
$ loop lst
loop: Unmatched argument at index 0: 'lst'
Did you mean: loop list?
Run 'loop --help' for usage.
exit code 2
$ loop pull org/model --revison v1
loop: Unknown options: '--revison', 'v1'
Possible solutions: --revision
Run 'loop pull --help' for usage.
exit code 2
$ loop pull org/model --qaunt Q8_0
loop: Unknown options: '--qaunt', 'Q8_0'
Run 'loop pull --help' for usage.
exit code 2
$ loop pull org/model --quant
loop: Missing required parameter for option '--quant' (<quant>)
Run 'loop pull --help' for usage.
exit code 2
$ loop download org/model
loop: Unmatched arguments from index 0: 'download', 'org/model'
Run 'loop --help' for usage.
exit code 2The handler replaces picocli's default, which prints the message and the full usage text. ex.getCommandLine() is the command where the error happened, so the hint names the right help page: loop --help for a top-level error and loop pull --help for an error inside pull. UnmatchedArgumentException.printSuggestions(ex, err) prints suggestions when the exception is an unmatched-argument error and prints nothing otherwise. The handler returns exitCodeOnInvalidInput(), which is 2 by default. The exit code is the number a program returns to the shell, where 0 means success and any other number means failure.
The six cases show these behaviors:
servandlstare unknown subcommands close toserveandlist. picocli suggests them withDid you mean: loop serve?. It finds candidates by splitting the typed word and every subcommand name into small groups of characters, comparing the groups (the classCosineSimilarityin picocli), and suggesting the names whose similarity is above a threshold.--revisonis close to--revision, and picocli printsPossible solutions: --revision.--qauntgets no suggestion. Swapping two letters changes several of the character groups the comparison uses, and the result stays below the threshold. Suggestions are a help, not a guarantee.--quantat the end of the line has no value:Missing required parameter for option '--quant' (<quant>).download org/modelis not close to any subcommand, so there is no suggestion, and both arguments are reported as unmatched.
loop uses this handler for all commands. A user who typed loop serv sees three short lines instead of the full help text of the top-level command.
Example 10: generating a bash completion script with AutoComplete
Shell completion finishes what the user has started typing when they press the Tab key. With loop pu and Tab, the shell types pull. It can also complete option names and the values of options. A shell gets this knowledge from a small script that is loaded into the shell session.
The question is how a Java tool provides such a script without anyone writing shell code by hand. picocli.AutoComplete generates a bash script (bash is the common Linux shell) from the same command model that picocli already built from the annotations.
The program generates the script for a two-command loop. The script is 259 lines long, so the program prints only the lines that show how the model was translated. Compare those lines with the annotations in the program.
import java.util.List;
import picocli.AutoComplete;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "loop", subcommands = {Completion.Pull.class, Completion.Serve.class})
public class Completion {
static class QuantNames extends java.util.ArrayList<String> {
QuantNames() { super(List.of("Q4_K_M", "Q5_K_M", "Q6_K", "Q8_0")); }
}
enum Engine { llama, vllm }
@Command(name = "pull") static class Pull {
@Option(names = "--quant", completionCandidates = QuantNames.class) String quant;
@Parameters(paramLabel = "MODEL") String model;
}
@Command(name = "serve") static class Serve {
@Option(names = {"-p", "--port"}) int port;
@Option(names = "--engine") Engine engine;
}
public static void main(String[] args) {
String script = AutoComplete.bash("loop", new CommandLine(new Completion()));
System.out.println(script.lines().count() + " lines");
script.lines()
.filter(l -> l.startsWith("function _") || l.contains("local commands=") || l.contains("_option_args=(")
|| l.contains("local flag_opts=") || l.contains("local arg_opts=") || l.startsWith("complete "))
.map(String::strip)
.forEach(System.out::println);
}
}259 lines
function _complete_loop() {
function _picocli_loop() {
local commands="pull serve"
local flag_opts=""
local arg_opts=""
function _picocli_loop_pull() {
local commands=""
local flag_opts=""
local arg_opts="'--quant'"
local quant_option_args=("Q4_K_M" "Q5_K_M" "Q6_K" "Q8_0") # --quant values
function _picocli_loop_serve() {
local commands=""
local flag_opts=""
local arg_opts="'-p' '--port' '--engine'"
local engine_option_args=("llama" "vllm") # --engine values
complete -F _complete_loop -o default loop loop.sh loop.bashAutoComplete.bash("loop", commandLine) returns the whole script as a string, 259 lines here. The program filters the lines to show how the command model became shell code:
function _complete_loop()is the entry point. It looks at the words typed so far and dispatches to one function per command:_picocli_loop,_picocli_loop_pull, and_picocli_loop_serve.local commands="pull serve"lists the subcommands that Tab offers afterloop.arg_optslists the options that take a value (--quant,-p,--port,--engine).flag_optswould list options without a value; these commands have none.quant_option_argsholds the values fromcompletionCandidates = QuantNames.class, any class that is anIterable<String>.engine_option_argscomes from the enum typeEnginewithout any extra code.complete -F _complete_loop -o default loop loop.sh loop.bashregisters the function for the command namesloop,loop.sh, andloop.bash.
In a real project, the build generates the script with picocli's own command-line entry point. A user then loads the script into the shell with the source command:
java -cp target/loop-0.1.0.jar picocli.AutoComplete -n loop dev.loop.Loop
source loop_completion
loop pu<Tab> # completes to: loop pull-n sets the command name, and dev.loop.Loop is the annotated class. The script is written to the file loop_completion in the current directory. The script also runs in zsh: when it detects zsh, it loads zsh's bash-completion compatibility layer (bashcompinit) itself. picocli also has a ready-made subcommand, AutoComplete.GenerateCompletion, which a tool adds to its subcommands list to offer loop generate-completion.
Example 11: a fat jar with the Maven Shade plugin and a launcher script
The loop classes need picocli and Jackson at run time. A normal Maven build produces a small jar with only the loop classes. To run it, the user would also need the jars of picocli and Jackson and would have to put all of them on the class path (the list of places where Java looks for classes). That is a lot to ask of a user.
The question is how to ship one file that contains everything. A fat jar (also called an uber jar) is a single jar with the application's classes and the classes of all its dependencies. The Maven Shade plugin builds one during mvn package. Maven is the build tool, and pom.xml is its configuration file.
The relevant part of pom.xml follows. It lists the two dependencies and configures the plugin.
<dependencies>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>dev.loop.Loop</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>The plugin configuration has four parts, and each one solves a specific problem:
- The
shadegoal runs in thepackagephase, after the normal jar is built. ManifestResourceTransformerwritesMain-Class: dev.loop.Loopinto the manifest, sojava -jarknows which class to start.ServicesResourceTransformermergesMETA-INF/servicesfiles from all jars. Libraries register plugins through these files (theServiceLoadermechanism), and without merging, one jar's file would overwrite another's.- The filter drops signature files (
*.SF,*.DSA,*.RSA), which would no longer match the combined jar and make the JVM reject it, and the dependencies'module-info.classfiles, which describe the separate jars and are wrong for the merged one.
mvn -B package
ls -l target/*.jar
java -jar target/loop-0.1.0.jar --versionThe three commands build the jar, list the files in target, and run the jar. This is the output from the course's build machine, with the lines about downloading dependencies removed:
[INFO] Including info.picocli:picocli:jar:4.7.7 in the shaded jar.
[INFO] Including com.fasterxml.jackson.core:jackson-databind:jar:2.22.3 in the shaded jar.
[INFO] Including com.fasterxml.jackson.core:jackson-annotations:jar:2.22 in the shaded jar.
[INFO] Including com.fasterxml.jackson.core:jackson-core:jar:2.22.3 in the shaded jar.
[WARNING] jackson-annotations-2.22.jar, jackson-core-2.22.3.jar, jackson-databind-2.22.3.jar define 2 overlapping resources:
[WARNING] - META-INF/LICENSE
[WARNING] - META-INF/NOTICE
[INFO] Replacing original artifact with shaded artifact.
[INFO] BUILD SUCCESS
-rw-r--r-- 1 root root 2799833 Oct 3 13:05 target/loop-0.1.0.jar
-rw-r--r-- 1 root root 11133 Oct 3 13:05 target/original-loop-0.1.0.jar
loop 0.1.0The Including lines list the four jars merged into the result: picocli, Jackson databind, and the two Jackson jars that databind depends on. The warning says that the three Jackson jars each contain files named META-INF/LICENSE and META-INF/NOTICE, and only one copy of each is kept. That is harmless for license files. For files that must be combined rather than overwritten, Shade has transformers such as the services one above.
The size listing shows the cost. The shaded jar is 2.8 MB. The original jar with only the loop classes, which Shade keeps as original-loop-0.1.0.jar, is 11 KB. The last line, loop 0.1.0, is the output of --version from the single file.
Typing java -jar and a path for every command is long. A launcher script hides both. It is installed as bin/loop next to lib/loop.jar:
#!/bin/sh
# Starts loop from the jar in ../lib relative to this script.
APP_HOME=$(cd "$(dirname "$0")/.." && pwd)
exec "${JAVA:-java}" ${LOOP_JAVA_OPTS:-} -jar "$APP_HOME/lib/loop.jar" "$@""$@" passes every argument through unchanged, including arguments that contain spaces. exec replaces the shell process with the JVM, so the exit code of loop becomes the exit code of the script, and signals such as Ctrl+C reach the JVM directly. JAVA and LOOP_JAVA_OPTS let a user choose a Java installation and JVM options, for example -Xmx2g, without editing the script. With the script on the PATH, loop rm x prints loop rm: not implemented yet (Project 10) and the shell sees exit code 1, as in the previous lesson.
Example 12: a custom runtime with jlink and an installer with jpackage
A fat jar still needs a Java installation on the user's machine. The question is how to ship loop together with its own Java runtime, so the user installs nothing else. The full JDK is large, so the runtime is cut down to the parts the program uses.
Since Java 9, the JDK is divided into modules, which are named groups of packages such as java.base (the core classes) and java.sql (database classes). Modules are described in Packages, Modules, and Reflection. Three tools in the JDK work with them:
jdepsanalyzes a jar and lists the JDK modules it uses.jlinkbuilds a runtime image, which is a directory that contains a Java runtime with only the modules you name.jpackagebundles the runtime and the jar into an application image (a directory with a native launcher) or into an installer.
The commands below find the modules, build the runtime, check its size and contents, and run the fat jar with it.
jdeps --multi-release 21 --print-module-deps --ignore-missing-deps target/loop-0.1.0.jar
jlink --add-modules java.base,java.desktop,java.sql,java.net.http \
--strip-debug --no-header-files --no-man-pages --compress zip-6 \
--output build/runtime
du -sh build/runtime
build/runtime/bin/java --list-modules
build/runtime/bin/java -jar target/loop-0.1.0.jar --versionThis is the output from the course's build machine with JDK 25:
java.base,java.desktop,java.sql
58M build/runtime
java.base@25.0.4.1
java.datatransfer@25.0.4.1
java.desktop@25.0.4.1
java.logging@25.0.4.1
java.net.http@25.0.4.1
java.prefs@25.0.4.1
java.sql@25.0.4.1
java.transaction.xa@25.0.4.1
java.xml@25.0.4.1
loop 0.1.0jdeps reads the classes in the jar and prints the JDK modules they reference. The first output line, java.base,java.desktop,java.sql, is its answer. --multi-release 21 tells it which version of multi-release classes to read, because picocli and Jackson ship some classes per Java version. --ignore-missing-deps skips references to optional libraries that are not in the jar.
Two of the three modules may be a surprise for a command-line tool. java.desktop and java.sql come from Jackson. Its databind.ext package references java.beans types, which live in java.desktop, and its date support references java.sql types (jdeps -verbose:package shows each reference). The jlink command adds java.net.http by hand, because the HttpClient code of the next two lessons is not in this jar yet, so jdeps cannot see that need.
The jlink flags, each checked against jlink --help of JDK 25:
| Flag | Effect |
|---|---|
--add-modules |
Root modules; jlink adds their dependencies (java.xml, java.logging, java.prefs, ...) automatically |
--strip-debug |
Removes debug information such as line number tables |
--no-header-files, --no-man-pages |
Leave out C headers and manual pages |
--compress zip-6 |
Compresses resources; zip-0 to zip-9 are accepted, zip-6 is the default; the older values 0, 1, 2 are deprecated |
--output |
The directory to create |
The result is 58 MB, which du reports on the second output line. The JDK 25 installation directory on the same machine is 330 MB. The java in the new runtime runs the fat jar and prints loop 0.1.0. --list-modules shows the four modules named with --add-modules plus the five extra modules that they need, nine in total.
jpackage wraps the runtime and the jar into an application image, which is a directory with a native launcher program, or into an installer. The commands below build an application image, list its files, run it, and then try to build a Debian installer:
mkdir input && cp target/loop-0.1.0.jar input/
jpackage --type app-image --name loop --app-version 0.1.0 \
--input input --main-jar loop-0.1.0.jar \
--runtime-image build/runtime --dest dist
find dist -maxdepth 3 | sort
dist/loop/bin/loop --version
jpackage --type deb --name loop --app-version 0.1.0 --input input --main-jar loop-0.1.0.jar \
--runtime-image build/runtime --dest dist --linux-deb-maintainer dev@example.comThis is the output from the course's build machine:
dist
dist/loop
dist/loop/bin
dist/loop/bin/loop
dist/loop/lib
dist/loop/lib/app
dist/loop/lib/libapplauncher.so
dist/loop/lib/loop.png
dist/loop/lib/runtime
loop 0.1.0
Bundler DEB Bundle skipped because of a configuration problem: Can not find fakeroot. Reason: Cannot run program "fakeroot": Exec failed, error: 2 (No such file or directory)
Advice to fix: Please install required packagesThe application image has a native executable bin/loop, so the user needs no shell script and no java on the PATH. It also has the jar under lib/app, the runtime from jlink under lib/runtime, and a default icon. --input is a directory whose files are all copied into the image, which is why the jar is copied into its own directory first. --runtime-image reuses the jlink output. Without it, jpackage runs jlink itself.
--type accepts app-image, rpm, and deb on Linux; on macOS the types are dmg and pkg, and on Windows exe and msi. jpackage cannot build an installer for another operating system, so a project that ships for three systems runs it on three machines, usually in a CI pipeline. The last two output lines come from the deb attempt. They show that installers need the platform's packaging tools, which are dpkg-deb and fakeroot on Debian and Ubuntu. The machine had the first but not the second, and jpackage said so and skipped the installer.
Example 13: a GraalVM native image with the picocli-codegen annotation processor
A Java program starts a JVM (Java virtual machine) on every run, and the JVM loads and prepares the program's classes before the first line of main runs. For a short command such as loop --version, that start-up time is noticeable. The question is how to compile loop into a native executable that starts without a JVM.
GraalVM's native-image tool compiles the application ahead of time into machine code for one operating system and CPU. It analyzes the program at build time and includes only the code it can prove is reachable. This analysis has a weak point. Reflection means that code finds and uses classes, fields, and methods by name at run time, and the analysis cannot see such uses. picocli reads annotations and sets fields by reflection, so native-image needs a list of what picocli will touch.
The picocli-codegen annotation processor writes that list. An annotation processor is a plugin that runs inside javac (the Java compiler). This one writes the classes, fields, and methods that picocli will access into configuration files, and native-image reads them from META-INF/native-image/ on the class path. This is the compiler plugin configuration:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>4.7.7</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Aproject=${project.groupId}/${project.artifactId}</arg>
</compilerArgs>
</configuration>
</plugin>-Aproject sets the subdirectory for the generated files, so that the configurations of several jars do not collide. After mvn package, these commands list the generated files and show the start of the main one:
find target/classes/META-INF | sort
head -12 target/classes/META-INF/native-image/picocli-generated/dev.loop/loop/reflect-config.jsonThis is the output from the course's build machine:
target/classes/META-INF
target/classes/META-INF/native-image
target/classes/META-INF/native-image/picocli-generated
target/classes/META-INF/native-image/picocli-generated/dev.loop
target/classes/META-INF/native-image/picocli-generated/dev.loop/loop
target/classes/META-INF/native-image/picocli-generated/dev.loop/loop/proxy-config.json
target/classes/META-INF/native-image/picocli-generated/dev.loop/loop/reflect-config.json
target/classes/META-INF/native-image/picocli-generated/dev.loop/loop/resource-config.json
[
{
"name" : "dev.loop.Loop",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"fields" : [
{ "name" : "spec" }
]
},
{reflect-config.json has one entry per class that picocli touches. The first entry is the Loop command class with its annotated field spec. The file also has entries for the subcommands and for picocli's own classes. resource-config.json lists resource bundles, which are files of translated text (none here). proxy-config.json lists interfaces that picocli implements with dynamic proxies, which are objects created at run time (none here). The Shade plugin copies these files into the fat jar.
GraalVM is not installed on the course's build machine, so the next build and run were not executed. The output below is representative, not captured:
native-image --no-fallback -jar target/loop-0.1.0.jar -o target/loop
./target/loop --versionRepresentative output of the last command:
loop 0.1.0--no-fallback makes the build fail instead of producing an image that still needs a JVM when something cannot be compiled ahead of time. -o names the executable. native-image prints a progress report with its build stages and the size of the result. The build is a separate step after mvn package, because it compiles the application and all its libraries to machine code for one operating system and CPU architecture.
The generated configuration covers picocli only. Jackson also uses reflection, for example to write the records of Example 8 as JSON, so a native loop needs entries for those classes too. GraalVM's tracing agent records the reflection that a real run performs and writes matching configuration files. It runs as java -agentlib:native-image-agent=config-output-dir=<dir> -jar ....
The table compares the three ways of shipping loop from Examples 11 to 13:
| Package | Needs on the user's machine | Size (this build) | Start |
|---|---|---|---|
| Fat jar plus script | A Java 21+ installation | 2.8 MB | JVM start |
jlink runtime or jpackage image |
Nothing | 58 to 61 MB | JVM start |
| Native image | Nothing | Not built here | No JVM start; built per operating system and CPU architecture |
Interview points
- picocli builds a command tree from
subcommands. An option is local to the level that declares it, unlessscope = ScopeType.INHERITcopies it to all subcommands. @ParentCommandgives a command the object of its parent command, andspec.commandLine().getParent()reaches higher levels.- A mixin adds a group of options to the selected commands that declare it, while an inherited option is added to all subcommands.
${sys:..},${env:..}, and${NAME:-fallback}indefaultValueare resolved when picocli parses the command line, and${DEFAULT-VALUE}shows the effective default in the help text.- picocli asks an
IDefaultValueProviderbefore it evaluates the annotation'sdefaultValue. A non-null value from the provider replaces the annotation default, including any${env:...}expression in it. The command line always wins. To rank environment variables above a config file, the provider has to check them first. - Secrets are read with
interactive = trueand achar[]field, which gives no echo on a terminal and leaves nothing in the shell history. An environment variable is the other safe way. - picocli's
Ansi.AUTOhonorspicocli.ansi,NO_COLOR,CLICOLOR_FORCE,CLICOLOR, and terminal detection withConsole.isTerminal()on Java 22 and later. - A table for people may change between versions, so scripts should read
--jsonoutput with raw values, which is the stable interface. - A custom
IParameterExceptionHandlerkeeps error messages short, andUnmatchedArgumentException.printSuggestionsadds "Did you mean" lines based on the cosine similarity of names. AutoCompletegenerates a bash completion script from the command model, including enum values andcompletionCandidates.- A fat jar built with Shade must merge
META-INF/services, drop signature files, and setMain-Class.jlinkplusjpackageship a private runtime.native-imageneeds reflection configuration, whichpicocli-codegengenerates for picocli only.
Summary
getSubcommands()returns a map from names toCommandLineobjects, andqualifiedName()is the full path such asloop config get.- A command that has no
run()orcall()method must be followed by a subcommand. Otherwise picocli reports "Missing required subcommand". - When an inherited option is given at two levels, the later value replaces the earlier one. The values are not added together.
- The usage text of a subcommand lists the inherited options and the mixed-in options.
${sys:loop.home:-${env:LOOP_HOME:-.loop}}nests lookups, each with a fallback.- A default-value provider that returns
nullmakes picocli fall back to the annotation'sdefaultValue. Values typed on the command line always win. setDefaultValueProvideron the top-levelCommandLinealso applies to its subcommands.- An interactive option with
arity = "0..1"accepts a value or prompts. Without a console, picocli readsSystem.in. -Dpicocli.ansi=trueforces colors on, andNO_COLORturns them off.Ansi.ON.string("@|bold,green done|@")produces\e[1m\e[32mdone\e[39m\e[21m\e[0m.- picocli suggests
serveforservand--revisionfor--revison, but it does not suggest--quantfor--qaunt. - The generated completion script for a tool with two commands is about 260 lines of bash.
- The shaded
loopjar is 2.8 MB, and ajlinkruntime for it is 58 MB. - On Linux,
jpackage --typeacceptsapp-image,rpm, anddeb. Adebneedsdpkg-debandfakeroot. picocli-codegenwritesreflect-config.json,resource-config.json, andproxy-config.jsonunderMETA-INF/native-image/picocli-generated/<project>/.
