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.

docsctl in picocli (Docsctl.java, abridged)
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

@Command attributes exitCodeOnSuccess, exitCodeOnInvalidInput, exitCodeOnExecutionException

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.

Callable<Integer> return value

The Integer returned from call() becomes the exit code from execute(…​).

Commands with one outcome per run, such as publish, which returns 5 when confirmation is missing.

IExitCodeGenerator

A command that implements getExitCode() supplies its code after it has run, including for a Runnable.

Commands with Runnable semantics that discover a problem while running, such as validate.

IExitCodeExceptionMapper

Set with CommandLine.setExitCodeExceptionMapper(…​), it maps an exception thrown by a command to a specific code.

Turning a hypothetical AuthRequiredException into 7 or a RateLimitedException into 6 in one place.

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:

Generating a completion script
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.

Generating man page sources
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:

Maven: the picocli-codegen annotation processor
<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):

Building the native executable
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.

Table 1. JVM versus native executable for a one-shot CLI
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, --version (Apple M2, JDK 25.0.2, picocli 4.7.7, 2026-09-30)

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

mvn package.

Adds a native-image step that takes noticeably longer and needs a GraalVM JDK.

Dynamic features

Reflection, proxies and resources just work.

Must be declared, which picocli-codegen does for picocli; other libraries may need extra configuration.

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.

Measuring both on your machine with hyperfine
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.

SearchCommands.java — docsctl search as a Spring Shell 4 command
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.

Repl.java — an optional docsctl shell with JLine
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.