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.
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);
}
}hello from picocli
exit code 0
exit code 2arguments -> parse against the model -> help or version request? -> run() -> exit codeThe 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 picoclicomes fromrun(). The first call passes no arguments, which matches a command with no options, so picocli callsrun().exit code 0is the valueexecutereturns afterrun()finishes without an exception. Exit code 0 means success to a shell, tomake, and to any program that starts this one.exit code 2belongs to the second call. The class declares no arguments, soextrais unmatched. picocli does not callrun(); it printsUnmatched 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.
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");
}
}$ 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 2The 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:
servewith no arguments uses every default.defaultValue = "8080"is a string that picocli converts with the same converter it uses for command-line text, soportis theint8080."models"becomes a relativePath, which is whyabsolute: falseis printed."PT10M"becomes aDurationof 600 seconds.ctxSizehas no default and its type is the wrapperInteger, so it staysnull. A primitiveintwithout a default would be 0, which a program cannot tell apart from a user who typed-c 0;Integerkeeps "not given" visible.- The second run sets every option.
/data/modelsis absolute.PT90Sis printed back asPT1M30SbecauseDuration.toStringnormalizes 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
abcto anintoption. picocli reportsInvalid value for option '--port': 'abc' is not an int, prints the usage text, and returns exit code 2 without callingrun(). 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.
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)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();
}
}$ 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 2index = "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/modelfillsrepoand leavesfilesas the empty list the field was initialized with.pull org/model a.gguf b.ggufputs the two file names into the list in order.pull org/model a.gguf --revision v2 b.ggufshows that options may appear between positional parameters. picocli recognizes--revisionby its name, consumesv2as its value, and continues with the positionalb.gguf.pull org/model -- --odd-name.ggufuses--, the end-of-options delimiter. Everything after--is positional even when it starts with a dash. Without--, picocli would report--odd-name.ggufas an unknown option.pullwith nothing reportsMissing 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.
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());
}
} 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-]progressdeclaration default typed result
--no-color (negatable, default false) false --no-color true
--color false
--progress (negatable, default true) true --progress false
--no-progress trueThe 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:
dryRunandnoColorarefalse, andprogressistruebecause ofdefaultValue = "true". --dry-runis a plain boolean option. picocli gives boolean options arity 0, so the flag takes no value, and its presence setstrue.--no-coloris declared with the negative name itself. Its negated form is--color. Typing--no-colorsetsnoColortotrue; typing--colorsets it tofalse, its default. With this declaration, the field name says whattruemeans.--progresswithdefaultValue = "true"setsprogresstofalse, and--no-progresssets it totrue. 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 oftrue, the regular form--progresstherefore 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.
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");
}
} 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-qyn5 is read as -q -y -n 5A 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:
-qysets two boolean options at once. picocli reads the cluster from left to right and treats each letter as a short option.-qyn5ends with an option that takes a value. When picocli reachesn, which expects anint, the rest of the cluster (5) becomes its value.-yn 7ends the cluster withnand 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.
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");
}
}
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.ggufadds one element per occurrence, in order, whichever name and spelling is used.--stop </s>,<|im_end|> --stop ###combines repetition withsplit = ",": 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 ofsplit.-o flash-attn=on -o cache-type-k=q8_0 --opt threads=8fills the map. The usage label is<String=String>, derived from the map's type parameters.LinkedHashMapkeeps the order the user typed; picocli would create a plainLinkedHashMaptoo if the field were leftnull.-o flash-attn=on -o flash-attn=offrepeats a key. The map keeps the last value,off, becauseMap.putreplaces the previous entry. A tool that wants to reject duplicates must check that itself.--gpu-split 3,1shows that arrays of primitives work the same way as lists:splitcuts the value and each part is converted todouble.
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.
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)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));
}
}
}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 1picocli 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():
tinysucceeds:call()printsremoved tinyand returnsExitCode.OK, 0.absentreturns 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.busythrowsIllegalStateException. The handler prints the message and returns 4 (IN_USE).brokenthrowsNoSuchFileException. The handler prints its message, which for this exception is only the file name, and returnsexitCodeOnExecutionException(), which is 1 unless the@Commandsets 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.
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"));
}
}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 0The usage text has five parts, each from one attribute:
- The synopsis,
Usage: fit [-hV] [-c=TOKENS] [--gpu-mem=MiB] [-np=N] MODEL, lists every option and parameter. Optional elements are in brackets;-hVis the cluster of the two boolean help options. - The description comes from
description. The markup@|bold weights|@is printed as plainweightswhen ANSI escape codes are off. - The parameter and option list has one row per element.
sortOptions = falsekeeps the declaration order instead of the alphabetical order of Example 2.showDefaultValues = trueadds aDefault:line for every option with a default;--gpu-memhas none, so its description says what absence means. mixinStandardHelpOptions = trueadds-h, --helpand-V, --versionwith their standard descriptions, and makes picocli handle them:--helpprints this text and--versionprints theversionattribute, both with exit code 0, without callingrun(). The last two lines of the output show--versionprintingfit 0.1and returning 0.- 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.
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"));
}
}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 0versionProvider = 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-03comes from the same properties.picocli 4.7.7is the constantCommandLine.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.
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 ParameterExceptionimport 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");
}
}$ 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.ggufpasses every check.placementisnullbecause no option of the group was given, so the program printsdefault placement.-m tiny.gguf --gpu-layers 20fills the group: picocli creates aPlacementobject and setsgpuLayersto 20.--cpu-onlyalone fails because--modelhasrequired = true. The message uses the option's longest name and its label:'--model=FILE'.--gpu-layers 20 --cpu-onlyfails 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. TheError:prefix is part of picocli's message for this case.-c 5000passes parsing, because 5000 is a validint, and fails the check inrun(). ThrowingParameterExceptionfrom 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:
(\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 optionalimport 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);
}
}
}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:
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 totalThe 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:
4GiBis 4 × 2^30 = 4,294,967,296 bytes. Theiunits (KiB, MiB, GiB, TiB) are powers of 1,024, the units that operating systems and llama.cpp logs use for memory (Memory Math).4GBis 4 × 10^9 = 4,000,000,000 bytes, which is 3.73 GiB. Units withoutiare powers of 1,000, the units disk vendors and many model cards use. The 7% difference between4GBand4GiBis the reason the converter keeps both.512MiBis 0.50 GiB,1.5GiBis exact, and24gibshows that case does not matter.1048576has no unit and is read as bytes.4 gigsdoes not match the pattern. The converter throwsTypeConversionExceptionwith its own message, and picocli prefixes it withInvalid value for option '--mem':.0.3Bmatches the pattern, but 0.3 bytes is not a whole number.longValueExactthrowsArithmeticException; picocli catches any exception from a converter and reports it ascannot convert '0.3B' to longwith the cause. A converter's ownTypeConversionExceptiongives 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.
org/Coder-7B-GGUF : Q4_K_M @ v1.1
repo quant revisionimport 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);
}
}
}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-GGUFhas no quant and no revision.org/Coder-7B-GGUF:q4_k_mis normalized toQ4_K_M, so later code compares quant names in one letter case.org/Coder-7B-GGUF:Q8_0@v1.1sets all three parts.org/Coder-7B-GGUF@refs/pr/3shows 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-GGUFhas no organization.org/Coder 7Bcontains a space.../etcmatches 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.
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");
}
}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 passedThree changes make the command testable:
- The command writes with
spec.commandLine().getOut(), notSystem.out. picocli'sgetOut()returns the writer set withsetOut, so the test sees the text. Output printed withSystem.outwould bypass the capture. run(String... args)creates a fresh command for each test, sets both writers toStringWriters, and returns the exit code and both texts as aResultrecord.- 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()becauseprintlnon aPrintWriterends 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. --helpexits 0 and prints to standard output, not standard error. That is the convention for requested help:loop --help | lessworks 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.
loop
pull list (ls) show rm fit
serve run chat bench
agent eval
helpEach 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.
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));
}
}
}$ 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 1The structure:
Loopis the top-level command. Itssubcommandslist holds eleven stub classes and picocli'sHelpCommand. When the first argument names a subcommand, picocli parses the rest of the arguments against that subcommand and calls itscall().Loop.run()runs only when no subcommand is given. It prints the usage text, so typingloopalone shows the list of commands with exit code 0.Stubis an abstract base class. Its@Specfield, its-h, --helpoption, and itscall()are inherited by every subcommand, because picocli scans the fields of superclasses too.usageHelp = truemarks the option as a help request, which picocli handles without callingcall().- Each subcommand adds its name, description, and parameters, and returns the number of the project that builds it.
The runs:
loopprints the usage with aCommands:section that lists each subcommand with its description, in the order of thesubcommandslist.list, lsshows the alias declared withaliases = "ls".loop --versionprintsloop 0.1.0from the top-levelversionattribute.loop pull --helpprints the subcommand's own usage. The synopsis starts withloop pull, the full command name, because picocli knows the parent.loop help fitprints the same kind of text throughHelpCommand, the form users ofgit help commitexpect.fithas an option with the default 4096 written as a field initializer; picocli uses the initial value of a field as its default whendefaultValueis absent.loop lsruns theliststub through its alias. The stub printsloop list: not implemented yet (Project 10)to standard error, usingspec.name(), which is the command's name, not the alias typed, and returns 1.loop fit org/model --ctx-size 8192parses the parameter and the option offitsuccessfully and reaches the stub'scall().
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.
executeparses the arguments, handles help and version requests, runs the command, and returns an exit code. It does not callSystem.exit. - The type of a field decides how text is converted.
defaultValuestrings go through the same converters as user input. A wrapper type such asIntegerlets 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 over1..*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 atruedefault therefore inverts the expected meaning. - A
boolean[]field counts repetitions (-vvvgives length 3), and short options can be clustered (-qyn5). List, array, andMapfields collect repeated options.splitcuts 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). AnIExecutionExceptionHandlercan print a one-line error and map exceptions to codes. - A
ParameterExceptionthrown from command code is treated as invalid input (exit code 2). Any other exception is an execution error. - An
ITypeConvertermoves the parsing of formats such as4GiBorrepo:quant@revto 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 withsetOut,setErr, andStringWriter.
Summary
new CommandLine(obj).execute(args)returns anint, and callingSystem.exitis 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-p9001are equivalent spellings.Durationoptions use ISO-8601 text such asPT10M, andPT90Sprints asPT1M30S.- The usage text sorts options alphabetically unless
sortOptions = false. --[no-]colorin a synopsis marks a negatable option.-vrepeated three times gives aboolean[]of length 3.- Options like
-o k=vfill aMap<String,String>, andsplit = ","turns3,1into two values. - picocli's default execution exception handler prints a stack trace and returns 1.
mixinStandardHelpOptionsadds-h, --helpand-V, --version. Requested help goes to standard output with exit code 0.- The markup
@|bold text|@becomes\e[1m ... \e[21m\e[0mwith ANSI on and plain text with ANSI off. IVersionProvider.getVersion()returns one string per output line, and${COMMAND-NAME}andCommandLine.VERSIONare available.- An exclusive
@ArgGrouprejects two of its options used together with the message "are mutually exclusive (specify only one)". 4GiBis 4,294,967,296 bytes and4GBis 4,000,000,000 bytes.- Project 8 is the
loopcommand with eleven stub subcommands plushelp, each with its own help page.
