Guide contents

Lesson 29 of 73

picocli Basics

A command-line tool is a program that you start by typing its name followed by arguments, for example ls -l /tmp. Sections 1 to 3 explained models, memory, and model files with small programs that each did one thing. From this lesson on, the course collects that work into one command-line tool, loop, so that a user can type loop pull, loop serve, or loop agent instead of editing and running separate programs.

A Java program receives its arguments as the String[] args array of main. Handling them by hand means looping over the array, converting text to numbers, checking for missing values, printing help, and choosing an exit code. An exit code is the number a program hands back to the shell when it ends. 0 means success, and any other number means some kind of failure. picocli is a Java library that does all of this work. You describe the command with annotations, and picocli parses the arguments, converts them to Java types, validates them, prints help, and returns an exit code. An annotation is a label placed on a class or field that a library can read (Enums and Annotations). picocli reads the annotations with reflection when a CommandLine is created.

After this lesson, a reader can write a picocli command with typed options and arguments, control its exit codes, test it without a terminal, and read the help text picocli generates. The lesson ends with Project 8, the loop command with one stub subcommand per feature the course builds.

Three words describe the parts of a command line. An option has a name, such as --port 9000. A parameter is identified by its position, such as the repository in pull org/model. A flag is an option without a value, such as --dry-run. The table lists what each example teaches.

Idea or API What it does Example
@Command, Runnable, CommandLine.execute Declares and runs a command 1
@Option with types and defaultValue Named options converted to int, Path, Duration, ... 2
@Parameters with index and arity Positional arguments 3
Boolean flags, negatable --dry-run, --no-color 4
Clustered short options, boolean[] -qy, -vvv 5
List and Map options, split Repeated options and -o key=value 6
Callable<Integer>, IExecutionExceptionHandler Exit codes and errors 7
mixinStandardHelpOptions, markup Generated usage text 8
IVersionProvider --version output computed at run time 9
required, ParameterException, @ArgGroup Validation 10
ITypeConverter Custom argument types 11, 12
setOut, setErr Testing a command 13
Subcommands Project 8, the loop skeleton 14

Every program in this lesson calls execute or parseArgs with fixed arguments written in main, so the output is the same on every run. A real tool passes the args array of main instead and ends with System.exit(exitCode). All examples use picocli 4.7.7, the version in the course's library directory, and compile on Java 21.

Example 1: the smallest command

The smallest picocli program needs two things: a @Command annotation on a class, and a method that picocli can call when the command runs. Runnable is a Java interface with one method, run(), and picocli uses it as that method. The program runs the command twice. The first call passes no arguments, and the second passes one argument that the command does not expect, so it shows what picocli does with bad input.

Java
import picocli.CommandLine;
import picocli.CommandLine.Command;

@Command(name = "hello", description = "Prints a greeting.")
public class Hello implements Runnable {
    @Override
    public void run() {
        System.out.println("hello from picocli");
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new Hello()).execute();          // like running: hello
        System.out.println("exit code " + exitCode);

        exitCode = new CommandLine(new Hello()).execute("extra");       // like running: hello extra
        System.out.println("exit code " + exitCode);
    }
}
Output
hello from picocli
exit code 0
exit code 2
Text
arguments -> parse against the model -> help or version request? -> run() -> exit code

The diagram shows what execute does, step by step.

new CommandLine(new Hello()) reads the annotations of the object and builds a model of the command: its name, its options, and its parameters. execute(String... args) then does four things in order: it parses the arguments against that model, handles help and version requests, calls run(), and returns an exit code.

The output has three lines:

  • hello from picocli comes from run(). The first call passes no arguments, which matches a command with no options, so picocli calls run().
  • exit code 0 is the value execute returns after run() finishes without an exception. Exit code 0 means success to a shell, to make, and to any program that starts this one.
  • exit code 2 belongs to the second call. The class declares no arguments, so extra is unmatched. picocli does not call run(); it prints Unmatched argument at index 0: 'extra' and the usage text to standard error and returns 2. Those error lines are not in the output block because the block shows only standard output; Example 2 sends them to standard output so they can be read.

execute never calls System.exit itself. A program that should end with the command's exit code writes System.exit(new CommandLine(new Hello()).execute(args)). Keeping exit out of execute lets tests (Example 13) call a command many times inside one JVM.

Example 2: options with types and default values

An option is a named argument such as --port 9000. On the command line everything is text, but the program needs an int for the port and a Path for a directory. picocli looks at the type of the field that carries the option and converts the text to that type. defaultValue gives the value used when the user does not type the option.

The program runs the command four times with different arguments. The line that starts with $ shows the arguments, like a shell prompt. The lines under it are what the command printed. The Duration type holds a length of time. Its text form, ISO-8601, writes PT10M for 10 minutes and PT90S for 90 seconds.

Java
import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Duration;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(name = "serve")
public class TypedOptions implements Runnable {
    @Option(names = {"-p", "--port"}, paramLabel = "PORT", defaultValue = "8080", description = "HTTP port") int port;
    @Option(names = "--models", paramLabel = "DIR", defaultValue = "models", description = "Model directory") Path models;
    @Option(names = "--idle-timeout", paramLabel = "DURATION", defaultValue = "PT10M", description = "Unload after idle time") Duration idleTimeout;
    @Option(names = {"-c", "--ctx-size"}, paramLabel = "TOKENS", description = "Context size") Integer ctxSize;               // no default: stays null

    @Override
    public void run() {
        System.out.printf("port=%d models=%s (absolute: %b) idle=%s (%d s) ctx=%s%n",
                port, models, models.isAbsolute(), idleTimeout, idleTimeout.toSeconds(), ctxSize);
    }

    static void demo(String... args) {
        CommandLine cl = new CommandLine(new TypedOptions());
        cl.setErr(new PrintWriter(System.out, true));                     // show picocli's errors in the output
        System.out.println(("$ serve " + String.join(" ", args)).strip());
        System.out.println("exit code " + cl.execute(args));
    }

    public static void main(String[] args) {
        demo();
        demo("--port", "9000", "--models", "/data/models", "--idle-timeout", "PT90S", "-c", "8192");
        demo("--port=9001", "-c16384");
        demo("-p", "abc");
    }
}
Output
$ serve
port=8080 models=models (absolute: false) idle=PT10M (600 s) ctx=null
exit code 0
$ serve --port 9000 --models /data/models --idle-timeout PT90S -c 8192
port=9000 models=/data/models (absolute: true) idle=PT1M30S (90 s) ctx=8192
exit code 0
$ serve --port=9001 -c16384
port=9001 models=models (absolute: false) idle=PT10M (600 s) ctx=16384
exit code 0
$ serve -p abc
Invalid value for option '--port': 'abc' is not an int
Usage: serve [-c=TOKENS] [--idle-timeout=DURATION] [--models=DIR] [-p=PORT]
  -c, --ctx-size=TOKENS   Context size
      --idle-timeout=DURATION
                          Unload after idle time
      --models=DIR        Model directory
  -p, --port=PORT         HTTP port
exit code 2

The demo method creates a fresh command for each run and calls cl.setErr(new PrintWriter(System.out, true)). By default picocli writes errors and usage text to System.err; this line redirects them to System.out so the output block shows them.

Run by run:

  • serve with no arguments uses every default. defaultValue = "8080" is a string that picocli converts with the same converter it uses for command-line text, so port is the int 8080. "models" becomes a relative Path, which is why absolute: false is printed. "PT10M" becomes a Duration of 600 seconds. ctxSize has no default and its type is the wrapper Integer, so it stays null. A primitive int without a default would be 0, which a program cannot tell apart from a user who typed -c 0; Integer keeps "not given" visible.
  • The second run sets every option. /data/models is absolute. PT90S is printed back as PT1M30S because Duration.toString normalizes 90 seconds into one minute and 30 seconds; both mean the same 90 seconds.
  • The third run shows the three spellings picocli accepts for an option value: --port 9001 (separate argument), --port=9001 (attached with =), and -c16384 (attached directly to a one-letter short option).
  • The fourth run gives abc to an int option. picocli reports Invalid value for option '--port': 'abc' is not an int, prints the usage text, and returns exit code 2 without calling run(). The message uses the longest name of the option (--port) even though the user typed -p.

Duration values use the ISO-8601 format of Duration.parse (PT30S, PT10M, PT1H). picocli has built-in converters for primitives and their wrappers, String, Path, File, URI, URL, BigDecimal, enums, and the java.time classes, among others. A friendlier format such as 30s needs a custom converter, which Example 11 writes for sizes.

The usage text lists options in alphabetical order of their names with the dashes ignored (-c, --idle-timeout, --models, -p). That is picocli's default, sortOptions = true. paramLabel sets the placeholder shown after the option (PORT, DIR); without it, picocli shows the field name in angle brackets, as in --revision=<revision> in the next example. When an option name and its label are too long for the first column, the description moves to the next line, as it does for --idle-timeout=DURATION.

Example 3: positional parameters, index and arity

A positional parameter is an argument identified by its position rather than by a name, such as the repository in pull org/model. The same idea appears in cp source destination: the first argument is the source and the second is the destination. The problem is how picocli assigns each position to a field and how many values a field takes. index picks the positions, and arity, the number of values a parameter accepts, follows from it.

Text
pull  org/model  a.gguf  b.gguf
      index 0    index 1  index 2
      REPO       FILE...  (index "1..*": the second argument and every one after it)
Java
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "pull")
public class Positionals implements Runnable {
    @Parameters(index = "0", paramLabel = "REPO", description = "Hugging Face repository") String repo;
    @Parameters(index = "1..*", paramLabel = "FILE", description = "Files to download (default: pick one)")
    List<String> files = new ArrayList<>();
    @Option(names = "--revision", defaultValue = "main", description = "Branch, tag, or commit") String revision;

    @Override
    public void run() {
        System.out.println("repo=" + repo + " revision=" + revision + " files=" + files);
    }

    static void demo(String... args) {
        CommandLine cl = new CommandLine(new Positionals());
        cl.setErr(new PrintWriter(System.out, true));
        System.out.println(("$ pull " + String.join(" ", args)).strip());
        System.out.println("exit code " + cl.execute(args));
    }

    public static void main(String[] args) {
        demo("org/model");
        demo("org/model", "a.gguf", "b.gguf");
        demo("org/model", "a.gguf", "--revision", "v2", "b.gguf");
        demo("org/model", "--", "--odd-name.gguf");
        demo();
    }
}
Output
$ pull org/model
repo=org/model revision=main files=[]
exit code 0
$ pull org/model a.gguf b.gguf
repo=org/model revision=main files=[a.gguf, b.gguf]
exit code 0
$ pull org/model a.gguf --revision v2 b.gguf
repo=org/model revision=v2 files=[a.gguf, b.gguf]
exit code 0
$ pull org/model -- --odd-name.gguf
repo=org/model revision=main files=[--odd-name.gguf]
exit code 0
$ pull
Missing required parameter: 'REPO'
Usage: pull [--revision=<revision>] REPO [FILE...]
      REPO        Hugging Face repository
      [FILE...]   Files to download (default: pick one)
      --revision=<revision>
                  Branch, tag, or commit
exit code 2

index = "0" binds the first positional argument to repo. A single-valued field with a single index has arity 1 by default, so the parameter is required. index = "1..*" binds every positional argument from the second one on to files, and a List field over an open range has arity 0..*: it may receive any number of values, including none.

The runs show the rules:

  • pull org/model fills repo and leaves files as the empty list the field was initialized with.
  • pull org/model a.gguf b.gguf puts the two file names into the list in order.
  • pull org/model a.gguf --revision v2 b.gguf shows that options may appear between positional parameters. picocli recognizes --revision by its name, consumes v2 as its value, and continues with the positional b.gguf.
  • pull org/model -- --odd-name.gguf uses --, the end-of-options delimiter. Everything after -- is positional even when it starts with a dash. Without --, picocli would report --odd-name.gguf as an unknown option.
  • pull with nothing reports Missing required parameter: 'REPO', prints the usage, and returns 2.

In the usage text, REPO has no brackets because it is required, and [FILE...] has brackets and dots because it is optional and repeatable. That notation is the convention of Unix manual pages, and picocli generates it from the index and arity. An explicit arity attribute overrides the default when needed, for example arity = "1..2" for a parameter that takes one or two values.

Example 4: boolean flags and negatable options

A flag is an option without a value whose presence switches something on, such as --dry-run. A negatable option also accepts a negative form, --no-NAME, that switches it off. The problem is what value each form sets. The answer depends on the field's default, and the output shows a case where the regular form --progress turns progress off, which looks backwards.

The program prints the three fields after parsing the arguments shown at the left of each line. The first line has no arguments.

Java
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

public class BooleanFlags {
    @Command static class Flags {
        @Option(names = "--dry-run") boolean dryRun;                                   // plain flag
        @Option(names = "--no-color", negatable = true) boolean noColor;                // default false
        @Option(names = "--progress", negatable = true, defaultValue = "true") boolean progress;
    }

    static void parse(String... args) {
        Flags f = new Flags();
        new CommandLine(f).parseArgs(args);
        System.out.printf("%-26s dryRun=%-5b noColor=%-5b progress=%b%n", String.join(" ", args), f.dryRun, f.noColor, f.progress);
    }

    public static void main(String[] args) {
        parse();
        parse("--dry-run");
        parse("--no-color");
        parse("--color");
        parse("--progress");
        parse("--no-progress");
        System.out.print(new CommandLine(new Flags()).getUsageMessage());
    }
}
Output
                           dryRun=false noColor=false progress=true
--dry-run                  dryRun=true  noColor=false progress=true
--no-color                 dryRun=false noColor=true  progress=true
--color                    dryRun=false noColor=false progress=true
--progress                 dryRun=false noColor=false progress=false
--no-progress              dryRun=false noColor=false progress=true
Usage: <main class> [--dry-run] [--[no-]color] [--[no-]progress]
      --dry-run
      --[no-]color
      --[no-]progress
Text
declaration                           default   typed            result
--no-color  (negatable, default false)  false     --no-color       true
                                                  --color          false
--progress  (negatable, default true)   true      --progress       false
                                                  --no-progress    true

The table summarizes the six lines of output that concern the two negatable options.

This example calls parseArgs instead of execute. parseArgs only fills the fields and returns a parse result; it does not call any method, print help, or catch errors. It is useful when a program wants the values and nothing else.

Line by line:

  • With no arguments, every field keeps its default: dryRun and noColor are false, and progress is true because of defaultValue = "true".
  • --dry-run is a plain boolean option. picocli gives boolean options arity 0, so the flag takes no value, and its presence sets true.
  • --no-color is declared with the negative name itself. Its negated form is --color. Typing --no-color sets noColor to true; typing --color sets it to false, its default. With this declaration, the field name says what true means.
  • --progress with defaultValue = "true" sets progress to false, and --no-progress sets it to true. picocli's documented rule for negatable options is: the regular form sets the opposite of the default, and the negated form sets the default. With a default of true, the regular form --progress therefore turns progress off.

The rule ties both forms to the default rather than to the words in the name. For loop, two declarations keep the name and the effect aligned: declare the negative name with a false default (--no-color, as in this example), or declare the positive name with a false default (--color), and never give a negatable option a true default. In the usage text both forms are listed as one entry, --[no-]color.

Example 5: clustered short options and repeated flags

Single-letter options can be combined after one dash, as in tar -xzf. The problem is how picocli splits such a cluster, and how a tool counts repetitions, as in -vvv for more detailed logging. The program parses several clusters, and the output shows the values each one produced.

Java
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

public class Verbosity {
    @Command static class Run {
        @Option(names = "-v", description = "More output; repeat for more (-vv, -vvv)") boolean[] verbose = new boolean[0];
        @Option(names = "-q") boolean quiet;
        @Option(names = "-y") boolean yes;
        @Option(names = "-n") int count;
    }

    static void parse(String... args) {
        Run r = new Run();
        new CommandLine(r).parseArgs(args);
        System.out.printf("%-14s verbosity=%d quiet=%b yes=%b n=%d%n", String.join(" ", args), r.verbose.length, r.quiet, r.yes, r.count);
    }

    public static void main(String[] args) {
        parse();
        parse("-v");
        parse("-vvv");
        parse("-v", "-v");
        parse("-qy");
        parse("-qyn5");
        parse("-yn", "7");
    }
}
Output
               verbosity=0 quiet=false yes=false n=0
-v             verbosity=1 quiet=false yes=false n=0
-vvv           verbosity=3 quiet=false yes=false n=0
-v -v          verbosity=2 quiet=false yes=false n=0
-qy            verbosity=0 quiet=true yes=true n=0
-qyn5          verbosity=0 quiet=true yes=true n=5
-yn 7          verbosity=0 quiet=false yes=true n=7
Text
-qyn5  is read as  -q  -y  -n 5

A field of type boolean[] collects one true per occurrence of the flag, so the array's length is the number of times it was given. That is the usual way to express verbosity levels: -v is 1, -vvv is 3, and -v -v is 2. The initial value new boolean[0] makes the length 0 when the flag is absent; without it the field would be null.

The cluster rules:

  • -qy sets two boolean options at once. picocli reads the cluster from left to right and treats each letter as a short option.
  • -qyn5 ends with an option that takes a value. When picocli reaches n, which expects an int, the rest of the cluster (5) becomes its value.
  • -yn 7 ends the cluster with n and nothing after it, so picocli takes the next argument, 7, as the value.

Clustering works only for options with one-letter names after a single dash. loop uses it for -v and -q and spells every option that takes a value with a long name as well, so scripts can use the readable form.

Example 6: multi-value options, lists and maps

Some options are given several times or take several values: a list of files, stop strings, or engine settings written as key=value. A single String field can hold only one value, so picocli chooses the collection by the field's type. A List or array collects every value, and a Map splits each value at the first =. The output prints each collection after parsing, and shows null for an option that was never given.

Java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

public class MultiValue {
    @Command static class Serve {
        @Option(names = {"-f", "--file"}, description = "Repeatable") List<String> files;
        @Option(names = "--stop", split = ",") List<String> stop;
        @Option(names = {"-o", "--opt"}, description = "Engine option KEY=VALUE") Map<String, String> engineOptions = new LinkedHashMap<>();
        @Option(names = "--gpu-split", split = ",") double[] gpuSplit;
    }

    static void parse(String... args) {
        Serve s = new Serve();
        new CommandLine(s).parseArgs(args);
        System.out.println(String.join(" ", args));
        System.out.println("  files=" + s.files + " stop=" + s.stop + " options=" + s.engineOptions
                + " gpuSplit=" + (s.gpuSplit == null ? "null" : java.util.Arrays.toString(s.gpuSplit)));
    }

    public static void main(String[] args) {
        parse();
        parse("-f", "a.gguf", "-f", "b.gguf", "--file=c.gguf");
        parse("--stop", "</s>,<|im_end|>", "--stop", "###");
        parse("-o", "flash-attn=on", "-o", "cache-type-k=q8_0", "--opt", "threads=8");
        parse("-o", "flash-attn=on", "-o", "flash-attn=off");
        parse("--gpu-split", "3,1");
    }
}
Output

  files=null stop=null options={} gpuSplit=null
-f a.gguf -f b.gguf --file=c.gguf
  files=[a.gguf, b.gguf, c.gguf] stop=null options={} gpuSplit=null
--stop </s>,<|im_end|> --stop ###
  files=null stop=[</s>, <|im_end|>, ###] options={} gpuSplit=null
-o flash-attn=on -o cache-type-k=q8_0 --opt threads=8
  files=null stop=null options={flash-attn=on, cache-type-k=q8_0, threads=8} gpuSplit=null
-o flash-attn=on -o flash-attn=off
  files=null stop=null options={flash-attn=off} gpuSplit=null
--gpu-split 3,1
  files=null stop=null options={} gpuSplit=[3.0, 1.0]

The first line of the output is empty because the first call passes no arguments. Its second line shows the defaults: files and stop are null because the fields have no initial value, and engineOptions is {} because it starts as an empty LinkedHashMap. A program that loops over an option's values either initializes the field or checks for null.

The other calls:

  • -f a.gguf -f b.gguf --file=c.gguf adds one element per occurrence, in order, whichever name and spelling is used.
  • --stop </s>,<|im_end|> --stop ### combines repetition with split = ",": each value is cut at commas, so the first occurrence adds two strings and the second adds one. A stop string is text at which generation ends (Generation and the KV Cache). A stop string that itself contains a comma is cut at that comma unless it is written in double quotes, and then picocli keeps the quote characters in the value; that extra care is the cost of split.
  • -o flash-attn=on -o cache-type-k=q8_0 --opt threads=8 fills the map. The usage label is <String=String>, derived from the map's type parameters. LinkedHashMap keeps the order the user typed; picocli would create a plain LinkedHashMap too if the field were left null.
  • -o flash-attn=on -o flash-attn=off repeats a key. The map keeps the last value, off, because Map.put replaces the previous entry. A tool that wants to reject duplicates must check that itself.
  • --gpu-split 3,1 shows that arrays of primitives work the same way as lists: split cuts the value and each part is converted to double.

loop serve passes the -o map to the engine as extra flags, so a user can set an engine option that loop does not know about.

Example 7: Callable<Integer> and exit codes

A command often needs to report more than success or failure: "model not found" and "model in use" call for different actions by a script, which can test the exit code. The problem is how a command chooses its exit code and how exceptions map to codes. Callable<Integer> is like Runnable, except that its method call() returns a value and may throw checked exceptions. picocli uses the returned number as the exit code. An execution exception handler maps exceptions to codes.

Text
model    what call() does                    exit code
tiny     prints "removed tiny", returns 0     0
absent   returns 3                            3
busy     throws IllegalStateException         4  (the handler maps it)
broken   throws NoSuchFileException           1  (the handler returns the default)
Java
import java.io.PrintWriter;
import java.nio.file.NoSuchFileException;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.ExitCode;
import picocli.CommandLine.IExecutionExceptionHandler;
import picocli.CommandLine.Parameters;

@Command(name = "rm")
public class ExitCodes implements Callable<Integer> {
    static final int NOT_FOUND = 3, IN_USE = 4;

    @Parameters(paramLabel = "MODEL") String model;

    @Override
    public Integer call() throws Exception {
        switch (model) {
            case "absent" -> { return NOT_FOUND; }                                  // a result, not an error
            case "busy" -> throw new IllegalStateException("model is in use by a running server");
            case "broken" -> throw new NoSuchFileException("models/broken/manifest.json");
            default -> { System.out.println("removed " + model); return ExitCode.OK; }
        }
    }

    public static void main(String[] args) {
        System.out.println("OK=" + ExitCode.OK + " SOFTWARE=" + ExitCode.SOFTWARE + " USAGE=" + ExitCode.USAGE);

        CommandLine plain = new CommandLine(new ExitCodes());                       // default handling
        System.out.println("busy, default handler -> " + plain.execute("busy"));

        IExecutionExceptionHandler handler = (ex, cl, parseResult) -> {
            cl.getErr().println("loop " + cl.getCommandName() + ": " + ex.getMessage());
            return ex instanceof IllegalStateException ? IN_USE : cl.getCommandSpec().exitCodeOnExecutionException();
        };
        for (String m : new String[] {"tiny", "absent", "busy", "broken"}) {
            CommandLine cl = new CommandLine(new ExitCodes()).setExecutionExceptionHandler(handler);
            cl.setErr(new PrintWriter(System.out, true));
            System.out.println(m + " -> exit code " + cl.execute(m));
        }
    }
}
Output
OK=0 SOFTWARE=1 USAGE=2
busy, default handler -> 1
removed tiny
tiny -> exit code 0
absent -> exit code 3
loop rm: model is in use by a running server
busy -> exit code 4
loop rm: models/broken/manifest.json
broken -> exit code 1

picocli names three conventional codes in CommandLine.ExitCode: OK (0) for success, SOFTWARE (1) for an exception during execution, and USAGE (2) for invalid input. The first line prints them.

The second line comes from a command without a custom handler. call() throws IllegalStateException; picocli's default handler prints the stack trace to standard error (not shown) and returns 1. A stack trace shows a developer where the error happened; a user of the tool needs only the message. The handler that follows prints only the message.

The handler is an IExecutionExceptionHandler, a functional interface with the parameters (exception, commandLine, parseResult). It prints one line in the form loop rm: message and chooses the code. Each model name in the loop takes one path through call():

  • tiny succeeds: call() prints removed tiny and returns ExitCode.OK, 0.
  • absent returns 3 without any exception. A return value is the way to report an expected outcome that is not success. The handler is not involved, and nothing is printed.
  • busy throws IllegalStateException. The handler prints the message and returns 4 (IN_USE).
  • broken throws NoSuchFileException. The handler prints its message, which for this exception is only the file name, and returns exitCodeOnExecutionException(), which is 1 unless the @Command sets another value.

Because call() may throw checked exceptions (throws Exception), I/O code inside a command does not need wrapping. For mapping exceptions to codes without printing, picocli also has IExitCodeExceptionMapper, set with setExitCodeExceptionMapper; the handler shown here does both printing and mapping in one place. Shells use 126 and 127 for "cannot execute" and "command not found" and report 128 + N for a process killed by signal N, so a tool's own codes stay below 126 to remain distinguishable. Exceptions covers checked and unchecked exceptions.

Example 8: generated help

Users type --help when they forget how a tool works, so every tool needs good help text. picocli generates it from the same annotations that define the options, so the help and the behavior cannot drift apart. This example declares a fit command with descriptions, defaults, a footer, and markup. It prints the usage text, which is the help screen, and then shows what the markup turns into on a color terminal. The first line of the usage text is the synopsis, a one-line summary of how to call the command.

Java
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Help.Ansi;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "fit", mixinStandardHelpOptions = true, version = "fit 0.1",
        description = "Estimates the memory a model needs. Prints @|bold weights|@, KV cache, and total.",
        sortOptions = false, showDefaultValues = true,
        footer = "Example: fit org/model:Q4_K_M --ctx-size 32768")
public class HelpText implements Runnable {
    @Parameters(paramLabel = "MODEL", description = "Model reference, @|italic repo[:quant]|@")
    String model;
    @Option(names = {"-c", "--ctx-size"}, paramLabel = "TOKENS", defaultValue = "4096", description = "Context length per slot")
    int ctx;
    @Option(names = {"-np", "--parallel"}, paramLabel = "N", defaultValue = "1", description = "Number of slots")
    int parallel;
    @Option(names = "--gpu-mem", paramLabel = "MiB", description = "GPU memory budget (default: none, CPU only)")
    Long gpuMem;

    @Override
    public void run() { }

    public static void main(String[] args) {
        CommandLine cl = new CommandLine(new HelpText());
        System.out.print(cl.getUsageMessage(Ansi.OFF));
        System.out.println("--- the description line with ANSI on, ESC shown as \\e:");
        String colored = cl.getUsageMessage(Ansi.ON).lines().skip(1).findFirst().orElseThrow();
        System.out.println(colored.replace("\u001B", "\\e"));
        System.out.println("--version exit code " + cl.execute("--version"));
    }
}
Output
Usage: fit [-hV] [-c=TOKENS] [--gpu-mem=MiB] [-np=N] MODEL
Estimates the memory a model needs. Prints weights, KV cache, and total.
      MODEL               Model reference, repo[:quant]
  -c, --ctx-size=TOKENS   Context length per slot
                            Default: 4096
      -np, --parallel=N   Number of slots
                            Default: 1
      --gpu-mem=MiB       GPU memory budget (default: none, CPU only)
  -h, --help              Show this help message and exit.
  -V, --version           Print version information and exit.
Example: fit org/model:Q4_K_M --ctx-size 32768
--- the description line with ANSI on, ESC shown as \e:
Estimates the memory a model needs. Prints \e[1mweights\e[21m\e[0m, KV cache, and total.
fit 0.1
--version exit code 0

The usage text has five parts, each from one attribute:

  1. The synopsis, Usage: fit [-hV] [-c=TOKENS] [--gpu-mem=MiB] [-np=N] MODEL, lists every option and parameter. Optional elements are in brackets; -hV is the cluster of the two boolean help options.
  2. The description comes from description. The markup @|bold weights|@ is printed as plain weights when ANSI escape codes are off.
  3. The parameter and option list has one row per element. sortOptions = false keeps the declaration order instead of the alphabetical order of Example 2. showDefaultValues = true adds a Default: line for every option with a default; --gpu-mem has none, so its description says what absence means.
  4. mixinStandardHelpOptions = true adds -h, --help and -V, --version with their standard descriptions, and makes picocli handle them: --help prints this text and --version prints the version attribute, both with exit code 0, without calling run(). The last two lines of the output show --version printing fit 0.1 and returning 0.
  5. The footer is printed last.

-np shows a detail of the layout. picocli puts one-character names such as -c in the short-option column and treats -np, two characters after one dash, as a long name, so it starts in the second column.

getUsageMessage(Ansi.ON) renders the same text with ANSI escape sequences, which are byte sequences that start with the escape character (code 27) and tell a terminal to change the style. The program replaces the escape character with \e to make them visible. \e[1m turns bold on before weights, \e[21m is the code picocli emits to end bold, and \e[0m resets all styles. With the default Ansi.AUTO, picocli decides by itself whether to emit these codes; when output goes to a file or a pipe, as it does when this example is checked, it emits none. Subcommands, Configuration, and Packaging shows the detection rules and the NO_COLOR convention.

Example 9: a version provider

A version string written in the annotation must be edited by hand for every release. The problem is how --version can print values that are known only when the program is built, such as the version number and the git commit. An IVersionProvider is a small class that computes the lines at the moment --version is requested. In the program, the provider reads a block of properties text that stands in for a file written by the build. Look at how the output lines match the parts of that text.

Java
import java.io.StringReader;
import java.util.Properties;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.IVersionProvider;

@Command(name = "loop", mixinStandardHelpOptions = true, versionProvider = VersionInfo.BuildInfo.class)
public class VersionInfo implements Runnable {
    static class BuildInfo implements IVersionProvider {
        // A real build writes this file into the jar (for example with Maven resource filtering).
        static final String BUILD_PROPERTIES = """
                version=0.1.0
                commit=3f2a9c1
                date=2026-10-03
                """;

        @Override
        public String[] getVersion() throws Exception {
            Properties p = new Properties();
            p.load(new StringReader(BUILD_PROPERTIES));
            return new String[] {
                "${COMMAND-NAME} " + p.getProperty("version"),
                "commit " + p.getProperty("commit") + ", built " + p.getProperty("date"),
                "picocli " + CommandLine.VERSION
            };
        }
    }

    @Override
    public void run() { }

    public static void main(String[] args) {
        System.out.println("exit code " + new CommandLine(new VersionInfo()).execute("--version"));
        System.out.println("exit code " + new CommandLine(new VersionInfo()).execute("-V"));
    }
}
Output
loop 0.1.0
commit 3f2a9c1, built 2026-10-03
picocli 4.7.7
exit code 0
loop 0.1.0
commit 3f2a9c1, built 2026-10-03
picocli 4.7.7
exit code 0

versionProvider = VersionInfo.BuildInfo.class tells picocli to instantiate BuildInfo and call getVersion() when the user passes --version or -V. The provider returns an array, and picocli prints one line per element.

The three lines come from three sources:

  • loop 0.1.0: ${COMMAND-NAME} is a variable that picocli replaces with the command's name, so the provider does not repeat it. The number comes from the properties text.
  • commit 3f2a9c1, built 2026-10-03 comes from the same properties.
  • picocli 4.7.7 is the constant CommandLine.VERSION, the version of the picocli jar on the class path. It helps when a bug report needs the library version.

In a real build, the properties text is a file such as version.properties in src/main/resources, with placeholders that Maven resource filtering replaces during the build; the provider reads it with getClass().getResourceAsStream. The example inlines the text so that it runs as one file. Both spellings, --version and -V, print the same lines and return 0.

Example 10: validation with required options, ParameterException, and argument groups

Some rules concern the combination of arguments rather than one value: an option that must be present, two options that cannot be used together, or a value that must be a multiple of 256. The problem is where each rule belongs. picocli checks the first two from annotations (required = true, and an argument group marked exclusive, which means "at most one of these options"). The third rule is checked in code and reported with ParameterException.

Text
rule                                    where it is checked
--model must be given                   annotation: required = true
--gpu-layers and --cpu-only not both    annotation: @ArgGroup(exclusive = true)
--ctx-size is a multiple of 256         code in run(): throw ParameterException
Java
import java.io.PrintWriter;
import picocli.CommandLine;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Spec;

@Command(name = "serve")
public class Validation implements Runnable {
    @Spec CommandSpec spec;                                       // picocli injects the command's model here

    @Option(names = {"-m", "--model"}, required = true, paramLabel = "FILE", description = "GGUF file") String model;
    @Option(names = {"-c", "--ctx-size"}, defaultValue = "4096", paramLabel = "TOKENS", description = "Context size") int ctx;

    @ArgGroup(exclusive = true, multiplicity = "0..1") Placement placement;

    static class Placement {
        @Option(names = "--gpu-layers", paramLabel = "N", description = "Layers to offload") Integer gpuLayers;
        @Option(names = "--cpu-only", description = "Run on the CPU") boolean cpuOnly;
    }

    @Override
    public void run() {
        if (ctx < 256 || ctx % 256 != 0)
            throw new ParameterException(spec.commandLine(), "--ctx-size must be a positive multiple of 256, got " + ctx);
        String where = placement == null ? "default placement" : placement.cpuOnly ? "CPU only" : placement.gpuLayers + " GPU layers";
        System.out.println("serving " + model + " with ctx " + ctx + ", " + where);
    }

    static void demo(String... args) {
        CommandLine cl = new CommandLine(new Validation());
        cl.setErr(new PrintWriter(System.out, true));
        cl.setParameterExceptionHandler((ex, a) -> {               // print only the message, not the usage
            ex.getCommandLine().getErr().println(ex.getMessage());
            return ex.getCommandLine().getCommandSpec().exitCodeOnInvalidInput();
        });
        System.out.println(("$ serve " + String.join(" ", args)).strip());
        System.out.println("exit code " + cl.execute(args));
    }

    public static void main(String[] args) {
        demo("-m", "tiny.gguf");
        demo("-m", "tiny.gguf", "--gpu-layers", "20");
        demo("--cpu-only");
        demo("-m", "tiny.gguf", "--gpu-layers", "20", "--cpu-only");
        demo("-m", "tiny.gguf", "-c", "5000");
    }
}
Output
$ serve -m tiny.gguf
serving tiny.gguf with ctx 4096, default placement
exit code 0
$ serve -m tiny.gguf --gpu-layers 20
serving tiny.gguf with ctx 4096, 20 GPU layers
exit code 0
$ serve --cpu-only
Missing required option: '--model=FILE'
exit code 2
$ serve -m tiny.gguf --gpu-layers 20 --cpu-only
Error: --gpu-layers=N, --cpu-only are mutually exclusive (specify only one)
exit code 2
$ serve -m tiny.gguf -c 5000
--ctx-size must be a positive multiple of 256, got 5000
exit code 2

@Spec CommandSpec spec asks picocli to inject the command's model into the field. The model gives access to the CommandLine (spec.commandLine()), which a ParameterException needs in order to print the right usage text. The demo method installs a parameter exception handler that prints only the message; picocli's default handler prints the message and the full usage text, as in Examples 2 and 3. Subcommands, Configuration, and Packaging describes this handler in detail.

The runs:

  • -m tiny.gguf passes every check. placement is null because no option of the group was given, so the program prints default placement.
  • -m tiny.gguf --gpu-layers 20 fills the group: picocli creates a Placement object and sets gpuLayers to 20.
  • --cpu-only alone fails because --model has required = true. The message uses the option's longest name and its label: '--model=FILE'.
  • --gpu-layers 20 --cpu-only fails because @ArgGroup(exclusive = true) allows at most one option of the group. multiplicity = "0..1" makes the group itself optional; "1" would require exactly one of the two. The Error: prefix is part of picocli's message for this case.
  • -c 5000 passes parsing, because 5000 is a valid int, and fails the check in run(). Throwing ParameterException from the command's code makes picocli treat it as invalid input: the parameter exception handler runs and the exit code is 2, not the 1 of an execution error.

The rule that follows from this: a check that depends only on the arguments throws ParameterException (exit code 2, "you typed something wrong"); a failure while doing the work throws any other exception (exit code 1 or a mapped code, "something went wrong"). Scripts can then distinguish a bad call from a failed run.

Example 11: a custom type converter for sizes

loop fit and loop pull take memory sizes such as 24GiB and 512MiB. The problem is how to teach picocli a value format it does not know. An ITypeConverter<T> is a class with one method that turns the text into a value of type T, and the converter attribute attaches it to an option. This converter turns size text into a number of bytes.

The converter uses a regular expression (a pattern for matching text) and BigDecimal, a Java class for exact decimal arithmetic, so that 1.5GiB does not suffer from the rounding of a double:

Text
(\d+(?:\.\d+)?)       the number: digits, with an optional fraction
\s*                   optional spaces
([KMGT]i?B|B)?        the unit: KB, KiB, MB, MiB, GB, GiB, TB, TiB, or B, all optional
Java
import java.io.PrintWriter;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.Option;
import picocli.CommandLine.TypeConversionException;

@Command(name = "fit")
public class SizeConverter implements Runnable {
    static class ByteSize implements ITypeConverter<Long> {
        static final Pattern SIZE = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([KMGT]i?B|B)?", Pattern.CASE_INSENSITIVE);
        static final Map<String, BigDecimal> UNITS = Map.of(
                "B", BigDecimal.ONE,
                "KB", BigDecimal.TEN.pow(3), "MB", BigDecimal.TEN.pow(6), "GB", BigDecimal.TEN.pow(9), "TB", BigDecimal.TEN.pow(12),
                "KIB", BigDecimal.valueOf(1L << 10), "MIB", BigDecimal.valueOf(1L << 20),
                "GIB", BigDecimal.valueOf(1L << 30), "TIB", BigDecimal.valueOf(1L << 40));

        @Override
        public Long convert(String value) {
            Matcher m = SIZE.matcher(value.strip());
            if (!m.matches()) throw new TypeConversionException("'" + value + "' is not a size such as 512MiB, 4GiB, or 8GB");
            String unit = m.group(2) == null ? "B" : m.group(2).toUpperCase(Locale.ROOT);
            return new BigDecimal(m.group(1)).multiply(UNITS.get(unit)).longValueExact();
        }
    }

    @Option(names = "--mem", converter = ByteSize.class, paramLabel = "SIZE", description = "Memory budget") long mem;

    @Override
    public void run() {
        System.out.printf(Locale.ROOT, "%,d bytes = %.2f GiB%n", mem, mem / (double) (1L << 30));
    }

    public static void main(String[] args) {
        for (String v : new String[] {"4GiB", "4GB", "512MiB", "1.5GiB", "24gib", "1048576", "4 gigs", "0.3B"}) {
            CommandLine cl = new CommandLine(new SizeConverter());
            cl.setErr(new PrintWriter(System.out, true));
            cl.setParameterExceptionHandler((ex, a) -> { cl.getErr().println("  " + ex.getMessage()); return 2; });
            System.out.printf("%-8s -> ", v);
            cl.execute("--mem", v);
        }
    }
}
Output
4GiB     -> 4,294,967,296 bytes = 4.00 GiB
4GB      -> 4,000,000,000 bytes = 3.73 GiB
512MiB   -> 536,870,912 bytes = 0.50 GiB
1.5GiB   -> 1,610,612,736 bytes = 1.50 GiB
24gib    -> 25,769,803,776 bytes = 24.00 GiB
1048576  -> 1,048,576 bytes = 0.00 GiB
4 gigs   ->   Invalid value for option '--mem': '4 gigs' is not a size such as 512MiB, 4GiB, or 8GB
0.3B     ->   Invalid value for option '--mem': cannot convert '0.3B' to long (java.lang.ArithmeticException: Rounding necessary)

The two kinds of unit differ in size:

Text
4GiB = 4 x 2^30 = 4 x 1,073,741,824 = 4,294,967,296 bytes
4GB  = 4 x 10^9                     = 4,000,000,000 bytes  = 3.73 GiB
4,000,000,000 / 4,294,967,296 = 0.93, so a "GB" is about 7% smaller than a "GiB" in total

The converter accepts a number with an optional fraction, optional spaces, and an optional unit, in any letter case. It multiplies with BigDecimal, so 1.5GiB is computed exactly as 1.5 × 2^30, and longValueExact refuses a result with a fractional byte.

The output lines:

  • 4GiB is 4 × 2^30 = 4,294,967,296 bytes. The i units (KiB, MiB, GiB, TiB) are powers of 1,024, the units that operating systems and llama.cpp logs use for memory (Memory Math).
  • 4GB is 4 × 10^9 = 4,000,000,000 bytes, which is 3.73 GiB. Units without i are powers of 1,000, the units disk vendors and many model cards use. The 7% difference between 4GB and 4GiB is the reason the converter keeps both.
  • 512MiB is 0.50 GiB, 1.5GiB is exact, and 24gib shows that case does not matter.
  • 1048576 has no unit and is read as bytes.
  • 4 gigs does not match the pattern. The converter throws TypeConversionException with its own message, and picocli prefixes it with Invalid value for option '--mem':.
  • 0.3B matches the pattern, but 0.3 bytes is not a whole number. longValueExact throws ArithmeticException; picocli catches any exception from a converter and reports it as cannot convert '0.3B' to long with the cause. A converter's own TypeConversionException gives a clearer message, which is why the pattern check comes first.

Example 12: a custom type converter for model references

loop pull names a model as repo[:quant][@revision], for example org/Coder-7B-GGUF:Q4_K_M@v1.1. The problem is how to split that text into its parts once, at the edge of the program (the place where user input first enters), so that the rest of the command works with typed fields and never re-parses text. A record is a Java class that holds fixed fields. The converter returns a ModelRef record.

Text
org/Coder-7B-GGUF : Q4_K_M @ v1.1
repo                quant     revision
Java
import java.io.PrintWriter;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.TypeConversionException;

@Command(name = "pull")
public class ModelRefConverter implements Runnable {
    /** repo[:quant][@revision]; quant is null when not given, revision defaults to main. */
    record ModelRef(String repo, String quant, String revision) {
        static final Pattern FORMAT = Pattern.compile("([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(?::([A-Za-z0-9_]+))?(?:@([A-Za-z0-9_./-]+))?");

        static ModelRef parse(String text) {
            Matcher m = FORMAT.matcher(text);
            if (!m.matches() || m.group(1).contains("..")) throw new TypeConversionException("'" + text + "' is not a model reference of the form org/name[:QUANT][@REVISION]");
            String quant = m.group(2) == null ? null : m.group(2).toUpperCase(Locale.ROOT);
            return new ModelRef(m.group(1), quant, m.group(3) == null ? "main" : m.group(3));
        }
    }

    static class ModelRefType implements ITypeConverter<ModelRef> {
        @Override
        public ModelRef convert(String value) { return ModelRef.parse(value); }
    }

    @Parameters(paramLabel = "MODEL", converter = ModelRefType.class) ModelRef ref;

    @Override
    public void run() { System.out.println(ref); }

    public static void main(String[] args) {
        for (String v : new String[] {"org/Coder-7B-GGUF", "org/Coder-7B-GGUF:q4_k_m", "org/Coder-7B-GGUF:Q8_0@v1.1",
                "org/Coder-7B-GGUF@refs/pr/3", "Coder-7B-GGUF", "org/Coder 7B", "../etc"}) {
            CommandLine cl = new CommandLine(new ModelRefConverter());
            cl.setErr(new PrintWriter(System.out, true));
            cl.setParameterExceptionHandler((ex, a) -> { cl.getErr().println(ex.getMessage()); return 2; });
            System.out.println(v);
            System.out.print("  ");
            cl.execute(v);
        }
    }
}
Output
org/Coder-7B-GGUF
  ModelRef[repo=org/Coder-7B-GGUF, quant=null, revision=main]
org/Coder-7B-GGUF:q4_k_m
  ModelRef[repo=org/Coder-7B-GGUF, quant=Q4_K_M, revision=main]
org/Coder-7B-GGUF:Q8_0@v1.1
  ModelRef[repo=org/Coder-7B-GGUF, quant=Q8_0, revision=v1.1]
org/Coder-7B-GGUF@refs/pr/3
  ModelRef[repo=org/Coder-7B-GGUF, quant=null, revision=refs/pr/3]
Coder-7B-GGUF
  Invalid value for positional parameter at index 0 (MODEL): 'Coder-7B-GGUF' is not a model reference of the form org/name[:QUANT][@REVISION]
org/Coder 7B
  Invalid value for positional parameter at index 0 (MODEL): 'org/Coder 7B' is not a model reference of the form org/name[:QUANT][@REVISION]
../etc
  Invalid value for positional parameter at index 0 (MODEL): '../etc' is not a model reference of the form org/name[:QUANT][@REVISION]

A model reference is the text a user types to name a model. The pattern has three parts: the repository (org/name, letters, digits, _, ., and -, as in The Hub HTTP API and Cache Layout in Java, Example 5), an optional :QUANT, and an optional @REVISION. The record holds the result: quant is null when not given, which later means "choose one that fits", and revision defaults to main.

The accepted inputs:

  • org/Coder-7B-GGUF has no quant and no revision.
  • org/Coder-7B-GGUF:q4_k_m is normalized to Q4_K_M, so later code compares quant names in one letter case.
  • org/Coder-7B-GGUF:Q8_0@v1.1 sets all three parts.
  • org/Coder-7B-GGUF@refs/pr/3 shows that a revision may contain slashes, because the Hub names pull-request revisions this way. The repository part cannot contain a third slash, so the parse stays unambiguous.

The rejected inputs:

  • Coder-7B-GGUF has no organization.
  • org/Coder 7B contains a space.
  • ../etc matches the character classes but contains ... The extra check rejects it, because the repository name later becomes part of a URL and a directory name, and .. would point outside the model directory.

The error message names the parameter by position and label, positional parameter at index 0 (MODEL). The converter is a static nested class with a no-argument constructor, because picocli creates it with reflection. The workshop in Workshop: loop pull, list, show, rm, and fit restates this record and adds a local model name derived from it.

Example 13: testing a command

A command-line tool is tested by running it with arguments and checking its output and exit code. The problem is that a command normally prints to the screen, where a test cannot read it. setOut and setErr replace the two output streams of a picocli command with writers that the test can read afterwards. The program below defines a small test helper, runs a command three times, and checks eight results. Each line of output says PASS or FAIL for one check.

Java
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;

public class CommandTest {
    @Command(name = "pull", mixinStandardHelpOptions = true, description = "Downloads a model.")
    static class Pull implements Callable<Integer> {
        @Spec CommandSpec spec;
        @Parameters(paramLabel = "REPO") String repo;
        @Option(names = "--dry-run") boolean dryRun;

        @Override
        public Integer call() {
            PrintWriter out = spec.commandLine().getOut();               // not System.out, so tests can capture it
            out.println((dryRun ? "would pull " : "pulling ") + repo);
            return 0;
        }
    }

    record Result(int exitCode, String out, String err) {}

    static Result run(String... args) {
        StringWriter out = new StringWriter(), err = new StringWriter();
        CommandLine cl = new CommandLine(new Pull());
        cl.setOut(new PrintWriter(out));
        cl.setErr(new PrintWriter(err));
        int code = cl.execute(args);
        return new Result(code, out.toString(), err.toString());
    }

    static int failures = 0;

    static void check(String name, boolean ok) {
        System.out.println((ok ? "PASS " : "FAIL ") + name);
        if (!ok) failures++;
    }

    public static void main(String[] args) {
        Result r = run("org/model", "--dry-run");
        check("dry run exits 0", r.exitCode() == 0);
        check("dry run prints the plan", r.out().equals("would pull org/model" + System.lineSeparator()));
        check("dry run writes nothing to stderr", r.err().isEmpty());

        r = run();
        check("missing repo exits 2", r.exitCode() == CommandLine.ExitCode.USAGE);
        check("missing repo names the parameter", r.err().startsWith("Missing required parameter: 'REPO'"));
        check("missing repo prints usage to stderr", r.err().contains("Usage: pull"));

        r = run("--help");
        check("help exits 0", r.exitCode() == 0);
        check("help goes to stdout", r.out().contains("Downloads a model.") && r.err().isEmpty());

        System.out.println(failures == 0 ? "all checks passed" : failures + " check(s) failed");
    }
}
Output
PASS dry run exits 0
PASS dry run prints the plan
PASS dry run writes nothing to stderr
PASS missing repo exits 2
PASS missing repo names the parameter
PASS missing repo prints usage to stderr
PASS help exits 0
PASS help goes to stdout
all checks passed

Three changes make the command testable:

  1. The command writes with spec.commandLine().getOut(), not System.out. picocli's getOut() returns the writer set with setOut, so the test sees the text. Output printed with System.out would bypass the capture.
  2. run(String... args) creates a fresh command for each test, sets both writers to StringWriters, and returns the exit code and both texts as a Result record.
  3. Each check compares exact values: the exit code, the full standard output, and the start or content of standard error.

The eight checks pass. They fall into three groups, one per command run, and each group pins down a behavior from earlier examples:

  • A successful run exits 0, prints exactly one line, and writes nothing to standard error. The comparison uses System.lineSeparator() because println on a PrintWriter ends lines with the platform's separator.
  • A missing parameter exits with ExitCode.USAGE (2), and the error text starts with the message from Example 3, followed by the usage text on standard error.
  • --help exits 0 and prints to standard output, not standard error. That is the convention for requested help: loop --help | less works because the text is on standard output, while error usage goes to standard error so it is not mixed into a pipe.

The check method stands in for a test framework so that the example runs as one program. The same tests in JUnit are one @Test method per check with assertEquals (JUnit); picocli itself needs nothing special.

Example 14: Project 8, the loop skeleton

Project 8 is the top-level loop command with one stub subcommand per feature the course builds. A stub is a placeholder that has the right name and help text but no working code yet. The problem is how one program offers many commands, each with its own options. A subcommand is a command nested under another, selected by the first argument, as git commit is under git. picocli registers them with the subcommands attribute; Subcommands, Configuration, and Packaging covers subcommands in depth.

Text
loop
  pull  list (ls)  show  rm  fit
  serve  run  chat  bench
  agent  eval
  help

Each stub reports the number of the project that will replace it. The program runs six command lines, and the output shows the help pages and the stub messages.

Java
import java.io.PrintWriter;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.HelpCommand;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;

@Command(name = "loop", mixinStandardHelpOptions = true, version = "loop 0.1.0",
        description = "Download, run, and serve local LLMs, and use them as a coding agent.",
        subcommands = {Loop.Pull.class, Loop.ListModels.class, Loop.Show.class, Loop.Rm.class, Loop.Fit.class,
                Loop.Serve.class, Loop.Run.class, Loop.Chat.class, Loop.Bench.class, Loop.Agent.class, Loop.Eval.class,
                HelpCommand.class})
public class Loop implements Runnable {
    @Spec CommandSpec spec;

    @Override
    public void run() { spec.commandLine().usage(spec.commandLine().getOut()); }   // "loop" alone prints help

    /** Every stub prints which project builds it and exits with 1. */
    abstract static class Stub implements Callable<Integer> {
        @Spec CommandSpec spec;
        @Option(names = {"-h", "--help"}, usageHelp = true, description = "Show this help message and exit.") boolean help;
        abstract int project();

        @Override
        public Integer call() {
            spec.commandLine().getErr().println("loop " + spec.name() + ": not implemented yet (Project " + project() + ")");
            return 1;
        }
    }

    @Command(name = "pull", description = "Download a GGUF model from Hugging Face.")
    static class Pull extends Stub { @Parameters(paramLabel = "MODEL", description = "repo[:quant][@revision]") String model; int project() { return 9; } }
    @Command(name = "list", aliases = "ls", description = "List downloaded models.")
    static class ListModels extends Stub { int project() { return 10; } }
    @Command(name = "show", description = "Show a model's metadata.")
    static class Show extends Stub { @Parameters(paramLabel = "MODEL", description = "Model name") String model; int project() { return 10; } }
    @Command(name = "rm", description = "Remove a downloaded model.")
    static class Rm extends Stub { @Parameters(paramLabel = "MODEL", description = "Model name") String model; int project() { return 10; } }
    @Command(name = "fit", description = "Estimate the memory a model needs.")
    static class Fit extends Stub { @Parameters(paramLabel = "MODEL", description = "Model name") String model; @Option(names = "--ctx-size", paramLabel = "TOKENS", description = "Context length") int ctx = 4096; int project() { return 10; } }
    @Command(name = "serve", description = "Start and supervise an inference engine.")
    static class Serve extends Stub { int project() { return 12; } }
    @Command(name = "run", description = "Chat with a model in the terminal.")
    static class Run extends Stub { @Parameters(paramLabel = "MODEL", description = "Model name") String model; int project() { return 13; } }
    @Command(name = "chat", description = "Chat with a running server.")
    static class Chat extends Stub { int project() { return 13; } }
    @Command(name = "bench", description = "Load-test an OpenAI-compatible server.")
    static class Bench extends Stub { int project() { return 15; } }
    @Command(name = "agent", description = "Run the coding agent.")
    static class Agent extends Stub { int project() { return 26; } }
    @Command(name = "eval", description = "Run the agent on a task set.")
    static class Eval extends Stub { int project() { return 28; } }

    public static void main(String[] args) {
        String[][] runs = {{}, {"--version"}, {"pull", "--help"}, {"help", "fit"}, {"ls"}, {"fit", "org/model", "--ctx-size", "8192"}};
        for (String[] a : runs) {
            CommandLine cl = new CommandLine(new Loop());
            cl.setErr(new PrintWriter(System.out, true));
            System.out.println(("$ loop " + String.join(" ", a)).strip());
            System.out.println("exit code " + cl.execute(a));
        }
    }
}
Output
$ loop
Usage: loop [-hV] [COMMAND]
Download, run, and serve local LLMs, and use them as a coding agent.
  -h, --help      Show this help message and exit.
  -V, --version   Print version information and exit.
Commands:
  pull      Download a GGUF model from Hugging Face.
  list, ls  List downloaded models.
  show      Show a model's metadata.
  rm        Remove a downloaded model.
  fit       Estimate the memory a model needs.
  serve     Start and supervise an inference engine.
  run       Chat with a model in the terminal.
  chat      Chat with a running server.
  bench     Load-test an OpenAI-compatible server.
  agent     Run the coding agent.
  eval      Run the agent on a task set.
  help      Display help information about the specified command.
exit code 0
$ loop --version
loop 0.1.0
exit code 0
$ loop pull --help
Usage: loop pull [-h] MODEL
Download a GGUF model from Hugging Face.
      MODEL    repo[:quant][@revision]
  -h, --help   Show this help message and exit.
exit code 0
$ loop help fit
Usage: loop fit [-h] [--ctx-size=TOKENS] MODEL
Estimate the memory a model needs.
      MODEL               Model name
      --ctx-size=TOKENS   Context length
  -h, --help              Show this help message and exit.
exit code 0
$ loop ls
loop list: not implemented yet (Project 10)
exit code 1
$ loop fit org/model --ctx-size 8192
loop fit: not implemented yet (Project 10)
exit code 1

The structure:

  • Loop is the top-level command. Its subcommands list holds eleven stub classes and picocli's HelpCommand. When the first argument names a subcommand, picocli parses the rest of the arguments against that subcommand and calls its call().
  • Loop.run() runs only when no subcommand is given. It prints the usage text, so typing loop alone shows the list of commands with exit code 0.
  • Stub is an abstract base class. Its @Spec field, its -h, --help option, and its call() are inherited by every subcommand, because picocli scans the fields of superclasses too. usageHelp = true marks the option as a help request, which picocli handles without calling call().
  • Each subcommand adds its name, description, and parameters, and returns the number of the project that builds it.

The runs:

  • loop prints the usage with a Commands: section that lists each subcommand with its description, in the order of the subcommands list. list, ls shows the alias declared with aliases = "ls".
  • loop --version prints loop 0.1.0 from the top-level version attribute.
  • loop pull --help prints the subcommand's own usage. The synopsis starts with loop pull, the full command name, because picocli knows the parent.
  • loop help fit prints the same kind of text through HelpCommand, the form users of git help commit expect. fit has an option with the default 4096 written as a field initializer; picocli uses the initial value of a field as its default when defaultValue is absent.
  • loop ls runs the list stub through its alias. The stub prints loop list: not implemented yet (Project 10) to standard error, using spec.name(), which is the command's name, not the alias typed, and returns 1.
  • loop fit org/model --ctx-size 8192 parses the parameter and the option of fit successfully and reaches the stub's call().

The skeleton fixes the shape of the tool before any feature exists: every command has a name, a description, a help page, and an exit code. Each later project replaces one stub with a working class with the same name. The next lesson adds options shared across subcommands, configuration files, colors, completion, and packaging.

Interview points

  • picocli builds a command model from annotations. execute parses the arguments, handles help and version requests, runs the command, and returns an exit code. It does not call System.exit.
  • The type of a field decides how text is converted. defaultValue strings go through the same converters as user input. A wrapper type such as Integer lets a program tell "not given" (null) from 0.
  • Positional parameters are bound by index. The arity comes from the field type: a single field is required, and a list over 1..* takes zero or more values. -- ends option parsing.
  • For a negatable option, the regular form sets the opposite of the default, and the --no- form sets the default. A negatable option with a true default therefore inverts the expected meaning.
  • A boolean[] field counts repetitions (-vvv gives length 3), and short options can be clustered (-qyn5).
  • List, array, and Map fields collect repeated options. split cuts one value into several, and a repeated map key keeps the last value.
  • The exit codes are 0 for success, 1 for an exception during execution, and 2 for invalid input (ExitCode). An IExecutionExceptionHandler can print a one-line error and map exceptions to codes.
  • A ParameterException thrown from command code is treated as invalid input (exit code 2). Any other exception is an execution error.
  • An ITypeConverter moves the parsing of formats such as 4GiB or repo:quant@rev to the edge of the program. An exception thrown by a converter becomes an "Invalid value" message.
  • A command that prints through spec.commandLine().getOut() can be tested with setOut, setErr, and StringWriter.

Summary

  • new CommandLine(obj).execute(args) returns an int, and calling System.exit is the job of the caller.
  • An unmatched argument, an invalid value, or a missing required option or parameter returns exit code 2 and prints the usage text to standard error.
  • --port 9001, --port=9001, and -p9001 are equivalent spellings.
  • Duration options use ISO-8601 text such as PT10M, and PT90S prints as PT1M30S.
  • The usage text sorts options alphabetically unless sortOptions = false.
  • --[no-]color in a synopsis marks a negatable option.
  • -v repeated three times gives a boolean[] of length 3.
  • Options like -o k=v fill a Map<String,String>, and split = "," turns 3,1 into two values.
  • picocli's default execution exception handler prints a stack trace and returns 1.
  • mixinStandardHelpOptions adds -h, --help and -V, --version. Requested help goes to standard output with exit code 0.
  • The markup @|bold text|@ becomes \e[1m ... \e[21m\e[0m with ANSI on and plain text with ANSI off.
  • IVersionProvider.getVersion() returns one string per output line, and ${COMMAND-NAME} and CommandLine.VERSION are available.
  • An exclusive @ArgGroup rejects two of its options used together with the message "are mutually exclusive (specify only one)".
  • 4GiB is 4,294,967,296 bytes and 4GB is 4,000,000,000 bytes.
  • Project 8 is the loop command with eleven stub subcommands plus help, each with its own help page.