Guide contents

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.

Java
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));
        }
    }
}
Output
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 2

The 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.port selects config, then get. picocli creates one object per level for this parse and sets Get.parent to the Config object, so get reads parent.file, which holds its default, config.json.
  • loop config --file work.json set server.port 9000 gives --file to config, the level where it is declared, before naming set. set sees work.json through its parent.
  • loop config set --file work.json server.port 9000 puts --file after set. An option belongs to the level that declares it, and --file is declared on config, not on set. So set does not know the option, and picocli reports it as unknown. The positional arguments then shift. set expects exactly two (KEY and VALUE), so work.json becomes KEY and server.port becomes VALUE. The last argument, 9000, has nowhere to go. This is why the message lists both --file and 9000.
  • loop config with no further argument fails with Missing required subcommand, because Config implements neither Runnable nor Callable. 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.

Java
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());
    }
}
Output
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/model uses the default home, .loop, and verbosity 0.
  • loop --home /srv/loop pull org/model sets the option on the top-level command, where it is declared.
  • loop pull org/model --home /srv/loop sets 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. pull reads it there through @ParentCommand, so the command code does not care where the user typed the option.
  • loop list -vv sets verbosity 2 from the subcommand's position.
  • loop -v list -vv gives 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 -v counted 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.

Java
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());
    }
}
Output
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 port

ServerOptions 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:

  • serve reads server.host and server.port; --port 8081 overrides the default 8080.
  • chat calls server.baseUrl(), which builds http://127.0.0.1:8081/v1 from 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 with with a key.
  • agent changes only the host; the port stays 8080.
  • The usage of agent lists its own --max-steps together 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.

Java
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());
    }
}
Output
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/loop would. The second line shows each of them taking effect. HF_ENDPOINT is found as a system property by the plain ${HF_ENDPOINT:-...} form, which looks there first.
  • The third line passes --home and --threads on the command line, and those win over every default. --hub was 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.

Java
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");
    }
}
Output
port=8080 ctx=4096 flash-attn=auto
port=9000 ctx=16384 flash-attn=auto
port=9100 ctx=16384 flash-attn=auto

The 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 attention auto.
  • The program then writes config.json with a serve section. The second run reads port 9000 and context 16,384 from the file. flash-attn is not in the file, so it keeps its annotation default. The pull section is ignored by serve.
  • 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.

Java
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());
    }
}
Output
Hugging Face token: received 15 characters: hf_************
exit code 0
received 13 characters: hf_**********
exit code 0
no token given
exit code 1

interactive = 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 the hf_ prefix and masking the rest.
  • --token hf_FromArgs99 takes the value from the arguments, 13 characters, with no prompt.
  • With no --token at all, the field stays null and 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.

Java
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"));
    }
}
Output
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:

  1. The system property picocli.ansi, when set to true or false, decides; tty means "use terminal detection only".
  2. If the environment variable NO_COLOR is set, to any value, colors are off. NO_COLOR is 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.
  3. If CLICOLOR_FORCE is set to anything other than 0, colors are on, even into a pipe.
  4. If CLICOLOR is 0, colors are off.
  5. Otherwise colors are on when System.console() exists and, on Java 22 and later, Console.isTerminal() returns true; 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:

  • auto prints plain text here, because AUTO is off in a pipe.
  • always emits escape codes, shown with \e: \e[1m (bold) and \e[32m (green) before done, \e[39m (default color) and \e[21m (end bold) after it, and \e[33m (yellow) around Q4_K_M.
  • never prints 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.

Java
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");
    }
}
Output
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:

  • %-36s left-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.
  • %10s right-aligns the size, so the decimal points of 4.36 GiB and 1.53 GiB line 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 use Locale.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:

Terminal
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.

Java
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));
        }
    }
}
Output
$ 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 2

The 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:

  • serv and lst are unknown subcommands close to serve and list. picocli suggests them with Did 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 class CosineSimilarity in picocli), and suggesting the names whose similarity is above a threshold.
  • --revison is close to --revision, and picocli prints Possible solutions: --revision.
  • --qaunt gets 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.
  • --quant at the end of the line has no value: Missing required parameter for option '--quant' (<quant>).
  • download org/model is 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.

Java
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);
    }
}
Output
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.bash

AutoComplete.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 after loop.
  • arg_opts lists the options that take a value (--quant, -p, --port, --engine). flag_opts would list options without a value; these commands have none.
  • quant_option_args holds the values from completionCandidates = QuantNames.class, any class that is an Iterable<String>. engine_option_args comes from the enum type Engine without any extra code.
  • complete -F _complete_loop -o default loop loop.sh loop.bash registers the function for the command names loop, loop.sh, and loop.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:

Terminal
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.

XML
<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 shade goal runs in the package phase, after the normal jar is built.
  • ManifestResourceTransformer writes Main-Class: dev.loop.Loop into the manifest, so java -jar knows which class to start.
  • ServicesResourceTransformer merges META-INF/services files from all jars. Libraries register plugins through these files (the ServiceLoader mechanism), 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.class files, which describe the separate jars and are wrong for the merged one.
Terminal
mvn -B package
ls -l target/*.jar
java -jar target/loop-0.1.0.jar --version

The 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:

Output
[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.0

The 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:

Terminal
#!/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.

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:

  • jdeps analyzes a jar and lists the JDK modules it uses.
  • jlink builds a runtime image, which is a directory that contains a Java runtime with only the modules you name.
  • jpackage bundles 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.

Terminal
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 --version

This is the output from the course's build machine with JDK 25:

Output
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.0

jdeps 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:

Terminal
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.com

This is the output from the course's build machine:

Output
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 packages

The 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:

XML
<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:

Terminal
find target/classes/META-INF | sort
head -12 target/classes/META-INF/native-image/picocli-generated/dev.loop/loop/reflect-config.json

This is the output from the course's build machine:

Output
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:

Terminal
native-image --no-fallback -jar target/loop-0.1.0.jar -o target/loop
./target/loop --version

Representative output of the last command:

Output
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, unless scope = ScopeType.INHERIT copies it to all subcommands.
  • @ParentCommand gives a command the object of its parent command, and spec.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} in defaultValue are resolved when picocli parses the command line, and ${DEFAULT-VALUE} shows the effective default in the help text.
  • picocli asks an IDefaultValueProvider before it evaluates the annotation's defaultValue. 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 = true and a char[] 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.AUTO honors picocli.ansi, NO_COLOR, CLICOLOR_FORCE, CLICOLOR, and terminal detection with Console.isTerminal() on Java 22 and later.
  • A table for people may change between versions, so scripts should read --json output with raw values, which is the stable interface.
  • A custom IParameterExceptionHandler keeps error messages short, and UnmatchedArgumentException.printSuggestions adds "Did you mean" lines based on the cosine similarity of names.
  • AutoComplete generates a bash completion script from the command model, including enum values and completionCandidates.
  • A fat jar built with Shade must merge META-INF/services, drop signature files, and set Main-Class. jlink plus jpackage ship a private runtime. native-image needs reflection configuration, which picocli-codegen generates for picocli only.

Summary

  • getSubcommands() returns a map from names to CommandLine objects, and qualifiedName() is the full path such as loop config get.
  • A command that has no run() or call() 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 null makes picocli fall back to the annotation's defaultValue. Values typed on the command line always win.
  • setDefaultValueProvider on the top-level CommandLine also applies to its subcommands.
  • An interactive option with arity = "0..1" accepts a value or prompts. Without a console, picocli reads System.in.
  • -Dpicocli.ansi=true forces colors on, and NO_COLOR turns them off.
  • Ansi.ON.string("@|bold,green done|@") produces \e[1m\e[32mdone\e[39m\e[21m\e[0m.
  • picocli suggests serve for serv and --revision for --revison, but it does not suggest --quant for --qaunt.
  • The generated completion script for a tool with two commands is about 260 lines of bash.
  • The shaded loop jar is 2.8 MB, and a jlink runtime for it is 58 MB.
  • On Linux, jpackage --type accepts app-image, rpm, and deb. A deb needs dpkg-deb and fakeroot.
  • picocli-codegen writes reflect-config.json, resource-config.json, and proxy-config.json under META-INF/native-image/picocli-generated/<project>/.