Java CLI Frameworks
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Java 21, picocli 4.7.7 and picocli-codegen 4.7.7, Spring Shell 4.0.3, JLine 4.4.6,
GraalVM Native Image and the docsctl running example of this sub-section, as checked on 2026-09-30. The picocli code was
compiled and run on the JVM with those versions, and the annotation processor, ManPageGenerator and AutoComplete were
executed. The native-image step itself was not run, because GraalVM is not installed on the machine used to write this page.
The fictional docsctl exists only on these pages, and its behaviour here is illustrative.
Java is the second primary implementation of docsctl. The JVM’s cost is start-up time, which matters for a command that runs
for a fraction of a second and is called many times by an agent; the answer is to compile the tool ahead of time with GraalVM
Native Image. picocli is the framework that makes both halves easy. The Java language itself is covered in
Java, and an application built on Quarkus is covered in
Command Mode and CLI Applications.
picocli
picocli builds a command-line interface from annotations on plain classes: @Command for commands and subcommands, @Option
for flags, @Parameters for positional operands. It generates --help and --version, validates types, reports usage errors, and
returns an exit code from CommandLine.execute(…), which is what main passes to System.exit. The example below is the
docsctl skeleton with two exit-code mechanisms, an inherited global --json flag and an exit-code table in --help.
package docsctl;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.IExitCodeGenerator;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(
name = "docsctl",
version = "docsctl 0.1.0",
mixinStandardHelpOptions = true,
description = "Search and publish this site's documentation.",
exitCodeOnInvalidInput = 2, // USAGE
exitCodeOnExecutionException = 1, // INTERNAL
exitCodeListHeading = "%nExit codes:%n",
exitCodeList = {
"0:Success",
"2:Usage error",
"3:Validation problems found",
"5:Confirmation required",
"7:Authentication required"},
subcommands = {Docsctl.Search.class, Docsctl.Validate.class, Docsctl.Publish.class})
public final class Docsctl implements Runnable {
static final int VALIDATION = 3;
static final int CONFIRMATION_REQUIRED = 5;
@Option(names = "--json", scope = CommandLine.ScopeType.INHERIT, description = "Emit JSON on stdout.")
boolean json;
@Override
public void run() {
throw new CommandLine.ParameterException(new CommandLine(this), "Missing subcommand");
}
@Command(name = "search", description = "Search the documentation.",
footer = "%nExample: docsctl search kalman --component irurueta-statistics --json")
static final class Search implements Callable<Integer> {
@Parameters(index = "0", paramLabel = "QUERY", description = "Words to look for.")
String query;
@Option(names = "--component", paramLabel = "NAME", description = "Restrict to one component.")
String component;
@Override
public Integer call() {
// data on stdout only; a real implementation would serialise with a JSON library
System.out.printf("{\"query\":\"%s\",\"results\":[],\"schemaVersion\":1,\"total\":0}%n", query);
return 0;
}
}
@Command(name = "validate", description = "Validate the sources.")
static final class Validate implements Runnable, IExitCodeGenerator {
private int exitCode = 0;
@Override
public void run() {
boolean problemsFound = true; // stand-in for the real check
if (problemsFound) {
exitCode = VALIDATION;
System.out.println("{\"ok\":false,\"schemaVersion\":1}");
}
}
@Override
public int getExitCode() {
return exitCode; // picocli uses this after run() completes
}
}
@Command(name = "publish", description = "Publish a component (destructive unless --dry-run).")
static final class Publish implements Callable<Integer> {
@Option(names = "--component", required = true, paramLabel = "NAME") String component;
@Option(names = "--dry-run") boolean dryRun;
@Option(names = {"--yes", "-y"}) boolean yes;
@Override
public Integer call() {
if (dryRun) {
System.out.println("{\"dryRun\":true,\"schemaVersion\":1}");
return 0;
}
if (!yes) {
System.err.println("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.");
return CONFIRMATION_REQUIRED; // the returned Integer becomes the exit code
}
return 0;
}
}
public static void main(String[] args) {
System.exit(new CommandLine(new Docsctl()).execute(args));
}
}
Running it produced the expected contract behaviour: docsctl validate exits with 3, docsctl publish --component
irurueta-algebra prints its message on stderr and exits with 5, --dry-run exits with 0, a missing QUERY exits with 2, and
--help ends with the exit-code table. Reference: picocli user manual.
Exit codes: four mechanisms
picocli offers four ways to choose the process exit status, and they compose:
| Mechanism | What it does | Use it for |
|---|---|---|
|
Sets the defaults for success, invalid input and an exception thrown from the command. |
Mapping picocli’s usage errors to the contract’s 2 and unexpected failures to 1. |
|
The |
Commands with one outcome per run, such as |
|
A command that implements |
Commands with |
|
Set with |
Turning a hypothetical |
Keep a single table of named constants shared by all commands, exactly as the contract’s table in
Streams & Exit Codes prescribes, and expose it in --help with exitCodeListHeading
and exitCodeList, as above, so an agent reads the codes from the tool. Reference:
picocli — Exit Code.
Shell completion with AutoComplete
picocli ships a completion generator, picocli.AutoComplete, that takes the fully qualified name of the @Command class and writes a completion
script. It is run from the command line with the picocli jar and your classes on the class path:
java -cp target/classes:picocli-4.7.7.jar picocli.AutoComplete -n docsctl -o docsctl_completion docsctl.Docsctl
-n names the command and -o names the output file (-f overwrites an existing file). The generated file is a bash completion
script, which the picocli manual also documents for zsh through bash-completion compatibility. Ship it, or expose the same content through a
docsctl completion subcommand, as the contract requires. Reference:
picocli — Autocomplete.
Man pages with ManPageGenerator
The picocli-codegen artifact contains picocli.codegen.docgen.manpage.ManPageGenerator. It reads the annotated command
classes and writes one AsciiDoc source per command (docsctl.adoc, docsctl-search.adoc, …), each in the man-page layout, which you then turn into roff
with Asciidoctor’s manpage backend, as described on Self-Describing Help.
java -cp target/classes:picocli-4.7.7.jar:picocli-codegen-4.7.7.jar \
picocli.codegen.docgen.manpage.ManPageGenerator --outdir=man docsctl.Docsctl
Because the pages come from the same annotations as --help, they cannot drift from it. Reference:
picocli — Generating Documentation.
picocli-codegen and GraalVM Native Image
GraalVM Native Image compiles a Java application ahead of time into a standalone native executable. Native Image analyses the code it
can reach at build time, so anything found only by reflection — which is how picocli reads annotations — has to be declared in
configuration files. The picocli-codegen annotation processor writes those files during compilation. Adding it to the compiler
plugin’s processor path is enough:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>4.7.7</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Aproject=${project.groupId}/${project.artifactId}</arg>
</compilerArgs>
</configuration>
</plugin>
Compiling the docsctl project with this configuration produced, under
target/classes/META-INF/native-image/picocli-generated/example/docsctl/, the files reflect-config.json, resource-config.json and
proxy-config.json. -Aproject= chooses the sub-directory, and Native Image picks up configuration from META-INF/native-image
on the class path automatically. Building the executable is then a single command, run from a GraalVM JDK (not run for this page):
native-image -jar target/docsctl-0.1.0.jar docsctl
That command assumes a runnable JAR that bundles picocli, which you get from the Maven Shade or Assembly plugin, or from
native-image with the class path listed. The result is a platform-specific executable, so build it once per operating system and processor architecture in CI; Packaging & Distribution
covers distribution. Two habits pay off: run the test suite (see Testing CLIs) against the native binary in CI
(reflection gaps show up at run time, not compile time), and keep dependencies that use heavy reflection to a minimum.
Reference: picocli — Annotation Processor and
GraalVM Native Image.
Startup time: JVM versus native
Start-up decides how a tool feels to an agent that calls it dozens of times in a session. Figures depend on the JDK, the machine, the
libraries and the command, and published figures age quickly, so the table quotes only what was measured for this page and compares the
rest qualitatively. The JVM figure comes from the picocli docsctl of this page, packaged as a runnable JAR that bundles picocli:
java -jar docsctl.jar --version was run 3 times as a warm-up and then 20 times, each run timed from process start to exit with
Python’s time.perf_counter, on an Apple M2 (arm64) with JDK 25.0.2 and picocli 4.7.7, on 2026-09-30. A second batch of 20 runs
gave 116.3 ± 2.2 ms, which shows how much such a number moves between batches on one machine. The native executable was not measured, because GraalVM
(and hyperfine) were not available on that machine; the hyperfine command below measures both forms side by side.
| Aspect | JVM (java -jar docsctl.jar) |
Native executable (./docsctl) |
|---|---|---|
Start-up |
Starts a JVM, loads and initialises classes, then runs; the cost is paid on every invocation. |
Starts as an ordinary process with the application already compiled, so it begins work almost immediately. |
Measured start-up, |
111.3 ± 1.6 ms (mean ± standard deviation of 20 runs after 3 warm-up runs) |
Not measured here: GraalVM was not available on the machine used for this page. |
Memory at start |
A JVM heap and runtime structures. |
Smaller footprint for a short-lived process. |
Peak throughput for long runs |
JIT compilation can optimise hot code. |
Ahead-of-time code; a long-running server may do better on the JVM. |
Build |
|
Adds a |
Dynamic features |
Reflection, proxies and resources just work. |
Must be declared, which |
Distribution |
Needs a suitable JRE on the target machine. |
One self-contained binary per OS and architecture. |
Diagnostics |
Full stack traces, JFR, standard tools. |
Different tooling; test the native binary itself. |
hyperfine --warmup 3 'java -jar target/docsctl-0.1.0.jar --version' './docsctl --version'
hyperfine runs each command repeatedly and prints the mean and spread. For tools that live for milliseconds the native form is usually the right default; for tools that do a lot of work per invocation the JVM is often good enough. Measure with the commands your agent actually issues.
Spring Shell
Spring Shell builds interactive and non-interactive command-line applications on Spring Boot. Commands are Spring beans declared with an annotation
model (@Command, with @Option and @Argument for parameters); the reference documents non-interactive execution of a single command
(the property spring.shell.interactive.enabled=false), exit-status handling through ExitStatus, colourised output and tables, and GraalVM native
support. It is a good fit when the tool is already a Spring application and reuses its beans, configuration and dependency
injection. For a standalone tool with fast start-up, picocli is the lighter choice, because a Spring context adds start-up work that native compilation only partly hides.
Check the annotation packages and the non-interactive properties of your Spring Shell release against its reference before copying anything, as they changed between major versions.
package docsctl;
import org.springframework.shell.core.command.annotation.Argument;
import org.springframework.shell.core.command.annotation.Command;
import org.springframework.shell.core.command.annotation.Option;
import org.springframework.stereotype.Component;
/** Your own service bean; returns the JSON document of the docsctl contract. */
interface DocsSearch {
String searchJson(String query, String component, int limit);
}
@Component
class SearchCommands {
private final DocsSearch docs;
SearchCommands(DocsSearch docs) {
this.docs = docs;
}
@Command(name = "search", description = "Search the documentation.", group = "docs",
help = "Example: docsctl search --component=irurueta-statistics kalman")
public String search(
@Argument(index = 0, description = "Words to look for") String query,
@Option(longName = "component", description = "Restrict to one component", defaultValue = "") String component,
@Option(longName = "limit", description = "Maximum results, 1 to 100", defaultValue = "10") int limit) {
return docs.searchJson(query, component, limit); // the returned value is printed
}
}
In Spring Shell 4.0.3 the annotations live in org.springframework.shell.core.command.annotation: @Command on a method of a
@Component class (Spring Boot registers it; outside Boot, list the class in @EnableCommand), @Argument(index = …) for a
positional parameter and @Option(longName = …, shortName = …) for a named one. A returned value is printed. Two details
differ from the docsctl contract: an option always takes a value, even a boolean one (--json=true, not --json), and short
options cannot be grouped, so a Spring Shell front end needs its own mapping for the contract’s flags. Map exceptions to exit codes
with an ExitStatusExceptionMapper bean named in @Command(exitStatusExceptionMapper = …). The class was compiled with
Java 25 against spring-shell-core 4.0.3 and spring-context 7.0.8, the versions spring-shell-core 4.0.3 depends on.
Reference: Spring Shell reference.
JLine
JLine is a library for console input in Java: a Terminal (built with TerminalBuilder), a LineReader (built with LineReaderBuilder) with editing,
history and completion, and a terminal type you can compare with Terminal.TYPE_DUMB to detect a terminal that cannot do
cursor control. It is what you add when a tool needs a real interactive prompt or a REPL, and also the piece that
fills the gap described in TTY, Colour & Interactivity: the JDK’s System.console() cannot report which of the standard streams is a terminal. picocli integrates with
it through the separate picocli-shell-jline3 module. Keep the interactive path optional: the non-interactive
mode remains the contract with agents.
package docsctl;
import java.nio.file.Path;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.UserInterruptException;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
/** {@code docsctl shell}: an optional interactive loop; agents keep using {@code docsctl search --json}. */
public final class Repl {
public static void main(String[] args) throws Exception {
try (Terminal terminal = TerminalBuilder.builder().system(true).build()) {
String type = terminal.getType();
if (Terminal.TYPE_DUMB.equals(type) || Terminal.TYPE_DUMB_COLOR.equals(type)) {
System.err.println("docsctl: shell needs a terminal; use `docsctl search --json -- QUERY` instead.");
System.exit(2);
}
LineReader reader = LineReaderBuilder.builder()
.terminal(terminal)
.appName("docsctl")
.variable(LineReader.HISTORY_FILE, Path.of(System.getProperty("user.home"), ".docsctl_history"))
.build();
while (true) {
String query;
try {
query = reader.readLine("search> ").strip();
} catch (UserInterruptException e) { // Ctrl+C: drop the current line
continue;
} catch (EndOfFileException e) { // Ctrl+D: leave the loop
break;
}
if (query.isEmpty()) {
continue;
}
if (query.equals("quit")) {
break;
}
terminal.writer().println(search(query));
terminal.flush();
}
}
}
private static String search(String query) {
return "results for: " + query; // call the same code as `docsctl search`
}
}
TerminalBuilder.builder().system(true).build() opens the process’s own terminal and falls back to a dumb terminal when there is
none, which is why the type check comes first: a pipe or an agent gets exit code 2 and the machine-readable command. readLine
signals Ctrl+C with UserInterruptException and end of input (Ctrl+D) with EndOfFileException, and
LineReader.HISTORY_FILE keeps history between sessions. This was compiled with Java 25 against org.jline:jline 4.4.6 (the
single bundle artifact); with standard input redirected it printed the message and exited with code 2, and under a
pseudo-terminal it answered a query and left on quit. On Java 22 and later the JLine terminal provider that uses the
Foreign Function and Memory API prints a restricted-method warning unless the program is started with
--enable-native-access=ALL-UNNAMED (or the module’s name). Reference: JLine.