Build Cli Applications
Problem
Command-line applications need robust argument parsing, help text generation, validation, and error handling. Manual argument parsing becomes complex with multiple options, flags, and subcommands. Users expect standard CLI behaviors like –help, –version, and clear error messages.
This guide shows how to build professional CLI applications in Java.
Picocli Framework
Basic CLI Application
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;
// ✅ Simple CLI with picocli
@Command(
name = "greet",
mixinStandardHelpOptions = true,
version = "1.0",
description = "Greets users with customizable message"
)
public class GreetCommand implements Callable<Integer> {
@Parameters(
index = "0",
description = "Name of the person to greet"
)
private String name;
@Option(
names = {"-c", "--capitalized"},
description = "Capitalize the greeting"
)
private boolean capitalized;
@Option(
names = {"-r", "--repeat"},
description = "Number of times to repeat greeting",
defaultValue = "1"
)
private int repeat;
@Override
public Integer call() {
String greeting = capitalized
? "HELLO, " + name.toUpperCase() + "!"
: "Hello, " + name + "!";
for (int i = 0; i < repeat; i++) {
System.out.println(greeting);
}
return 0; // Exit code 0 = success
}
public static void main(String[] args) {
int exitCode = new CommandLine(new GreetCommand()).execute(args);
System.exit(exitCode);
}
}Usage:
java -jar greet.jar Alice
java -jar greet.jar --capitalized --repeat 3 Bob
java -jar greet.jar --help
java -jar greet.jar --versionWhy picocli: Annotation-based configuration, automatic help generation, type-safe option handling, subcommands support, and comprehensive validation.
Argument Types and Validation
@Command(name = "process", description = "Process data files")
public class ProcessCommand implements Callable<Integer> {
// ✅ File parameter with validation
@Parameters(
index = "0",
description = "Input file to process"
)
private File inputFile;
// ✅ Multiple values (array)
@Option(
names = {"-f", "--format"},
description = "Output formats (valid: ${COMPLETION-CANDIDATES})",
split = ","
)
private Format[] formats = {Format.JSON};
enum Format { JSON, XML, CSV }
// ✅ Required option
@Option(
names = {"-o", "--output"},
description = "Output directory",
required = true
)
private File outputDir;
// ✅ Numeric range validation
@Option(
names = {"-t", "--threads"},
description = "Number of threads (1-16)",
defaultValue = "4"
)
private int threads;
// ✅ Custom validation
@Override
public Integer call() {
// Validate input file exists
if (!inputFile.exists()) {
System.err.println("Error: Input file does not exist: " + inputFile);
return 1;
}
// Validate output directory
if (!outputDir.exists() && !outputDir.mkdirs()) {
System.err.println("Error: Cannot create output directory: " + outputDir);
return 1;
}
// Validate thread count
if (threads < 1 || threads > 16) {
System.err.println("Error: Thread count must be between 1 and 16");
return 1;
}
processFile(inputFile, outputDir, formats, threads);
return 0;
}
private void processFile(File input, File output, Format[] formats, int threads) {
System.out.printf("Processing %s with %d threads%n", input, threads);
System.out.printf("Output directory: %s%n", output);
System.out.printf("Formats: %s%n", Arrays.toString(formats));
// Implementation...
}
public static void main(String[] args) {
System.exit(new CommandLine(new ProcessCommand()).execute(args));
}
}Subcommands
// ✅ Main command with subcommands
@Command(
name = "user",
mixinStandardHelpOptions = true,
description = "User management CLI",
subcommands = {
UserCreateCommand.class,
UserListCommand.class,
UserDeleteCommand.class
}
)
public class UserCommand implements Callable<Integer> {
@Override
public Integer call() {
System.err.println("Please specify a subcommand. Use --help for usage.");
return 1;
}
public static void main(String[] args) {
System.exit(new CommandLine(new UserCommand()).execute(args));
}
}
// ✅ Create subcommand
@Command(
name = "create",
description = "Create a new user"
)
class UserCreateCommand implements Callable<Integer> {
@Option(names = {"-e", "--email"}, required = true, description = "User email")
private String email;
@Option(names = {"-n", "--name"}, required = true, description = "User name")
private String name;
@Option(names = {"--admin"}, description = "Create as admin user")
private boolean admin;
@Override
public Integer call() {
System.out.printf("Creating user: %s <%s>%s%n",
name, email, admin ? " (admin)" : "");
// Create user...
System.out.println("User created successfully");
return 0;
}
}
// ✅ List subcommand
@Command(
name = "list",
description = "List all users"
)
class UserListCommand implements Callable<Integer> {
@Option(names = {"--admin-only"}, description = "Show only admin users")
private boolean adminOnly;
@Option(names = {"-l", "--limit"}, description = "Limit number of results", defaultValue = "10")
private int limit;
@Override
public Integer call() {
System.out.printf("Listing %s users (limit: %d)%n",
adminOnly ? "admin" : "all", limit);
// List users...
return 0;
}
}
// ✅ Delete subcommand
@Command(
name = "delete",
description = "Delete a user"
)
class UserDeleteCommand implements Callable<Integer> {
@Parameters(index = "0", description = "User ID to delete")
private String userId;
@Option(names = {"-f", "--force"}, description = "Skip confirmation")
private boolean force;
@Override
public Integer call() {
if (!force) {
System.out.print("Are you sure? (y/N): ");
Scanner scanner = new Scanner(System.in);
String response = scanner.nextLine();
if (!response.equalsIgnoreCase("y")) {
System.out.println("Cancelled");
return 0;
}
}
System.out.printf("Deleting user: %s%n", userId);
// Delete user...
System.out.println("User deleted successfully");
return 0;
}
}Usage:
java -jar user.jar --help
java -jar user.jar create --email alice@example.com --name Alice
java -jar user.jar list --admin-only --limit 5
java -jar user.jar delete user-123 --forceExit Codes and Error Handling
Standard Exit Codes
public class ExitCodes {
public static final int SUCCESS = 0;
public static final int GENERAL_ERROR = 1;
public static final int INVALID_ARGUMENTS = 2;
public static final int FILE_NOT_FOUND = 3;
public static final int PERMISSION_DENIED = 4;
public static final int NETWORK_ERROR = 5;
}
@Command(name = "backup")
public class BackupCommand implements Callable<Integer> {
@Parameters(index = "0", description = "Source directory")
private File source;
@Parameters(index = "1", description = "Destination directory")
private File destination;
@Override
public Integer call() {
// Validate source exists
if (!source.exists()) {
System.err.println("Error: Source directory does not exist: " + source);
return ExitCodes.FILE_NOT_FOUND;
}
// Validate source is readable
if (!source.canRead()) {
System.err.println("Error: Cannot read source directory: " + source);
return ExitCodes.PERMISSION_DENIED;
}
// Validate destination is writable
if (destination.exists() && !destination.canWrite()) {
System.err.println("Error: Cannot write to destination: " + destination);
return ExitCodes.PERMISSION_DENIED;
}
try {
performBackup(source, destination);
System.out.println("Backup completed successfully");
return ExitCodes.SUCCESS;
} catch (IOException e) {
System.err.println("Error during backup: " + e.getMessage());
return ExitCodes.GENERAL_ERROR;
}
}
private void performBackup(File source, File dest) throws IOException {
// Implementation...
}
}Graceful Error Messages
@Command(name = "convert")
public class ConvertCommand implements Callable<Integer> {
@Parameters(index = "0", description = "Input file")
private File inputFile;
@Option(names = {"-f", "--format"}, description = "Output format", required = true)
private String format;
@Override
public Integer call() {
try {
validateInputs();
convert(inputFile, format);
return 0;
} catch (FileNotFoundException e) {
printError("File not found: " + e.getMessage());
return 3;
} catch (UnsupportedFormatException e) {
printError("Unsupported format: " + format);
printError("Supported formats: JSON, XML, CSV");
return 2;
} catch (Exception e) {
printError("Conversion failed: " + e.getMessage());
if (System.getenv("DEBUG") != null) {
e.printStackTrace(System.err);
}
return 1;
}
}
private void validateInputs() throws FileNotFoundException {
if (!inputFile.exists()) {
throw new FileNotFoundException(inputFile.getPath());
}
}
private void convert(File input, String format) throws Exception {
// Implementation...
}
private void printError(String message) {
System.err.println("Error: " + message);
}
}stdin/stdout/stderr Usage
Reading from stdin
@Command(name = "filter", description = "Filter lines from input")
public class FilterCommand implements Callable<Integer> {
@Option(names = {"-p", "--pattern"}, description = "Pattern to match", required = true)
private String pattern;
@Option(names = {"-i", "--input"}, description = "Input file (default: stdin)")
private File inputFile;
@Override
public Integer call() throws IOException {
Pattern regex = Pattern.compile(pattern);
try (BufferedReader reader = createReader()) {
String line;
while ((line = reader.readLine()) != null) {
if (regex.matcher(line).find()) {
System.out.println(line); // stdout for output
}
}
}
return 0;
}
private BufferedReader createReader() throws IOException {
if (inputFile != null) {
return new BufferedReader(new FileReader(inputFile));
} else {
// Read from stdin
return new BufferedReader(new InputStreamReader(System.in));
}
}
}Usage:
java -jar filter.jar --pattern "ERROR" --input app.log
cat app.log | java -jar filter.jar --pattern "ERROR"
java -jar filter.jar --pattern "ERROR" < app.logWriting to stdout and stderr
@Command(name = "process")
public class ProcessCommand implements Callable<Integer> {
@Option(names = {"-v", "--verbose"}, description = "Verbose output")
private boolean verbose;
@Override
public Integer call() {
// ✅ Normal output to stdout
System.out.println("Processing started");
// ✅ Errors to stderr
try {
processData();
} catch (Exception e) {
System.err.println("ERROR: " + e.getMessage());
return 1;
}
// ✅ Verbose/diagnostic info to stderr
if (verbose) {
System.err.println("Processed 1000 items in 5 seconds");
}
// ✅ Result to stdout
System.out.println("Processing completed");
return 0;
}
private void processData() throws Exception {
// Implementation...
}
}Why stdout vs stderr: stdout for program output (can be piped), stderr for diagnostic messages and errors (visible to user but not piped).
JAR Packaging
Creating Executable JAR
Maven configuration (pom.xml):
<build>
<plugins>
<!-- Create executable JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.example.MyCommand</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<!-- Include dependencies in JAR (fat JAR) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.MyCommand</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>Build and run:
mvn clean package
java -jar target/myapp-1.0.jar --helpShell Script Wrapper
#!/bin/bash
if [ -n "$JAVA_HOME" ]; then
JAVA="$JAVA_HOME/bin/java"
else
JAVA="java"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
JAR_FILE="$SCRIPT_DIR/myapp.jar"
if [ ! -f "$JAR_FILE" ]; then
echo "Error: JAR file not found: $JAR_FILE" >&2
exit 1
fi
exec "$JAVA" -jar "$JAR_FILE" "$@"Installation:
chmod +x mycli
sudo cp mycli /usr/local/bin/
mycli --help
mycli process input.txtGraalVM Native Image
Creating Native Binaries
Maven configuration:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.28</version>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>com.example.MyCommand</mainClass>
<imageName>mycli</imageName>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>Build native binary:
mvn clean package -Pnative
./target/mycli --help
time ./target/mycli --versionBenefits of native image:
- Near-instant startup (important for CLI tools)
- Lower memory footprint
- No JVM required on target system
- Single executable file
Testing CLI Applications
Unit Testing Commands
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class GreetCommandTest {
@Test
void shouldGreetUser() {
GreetCommand command = new GreetCommand();
command.name = "Alice";
command.capitalized = false;
command.repeat = 1;
int exitCode = command.call();
assertEquals(0, exitCode);
}
@Test
void shouldCapitalizeGreeting() {
GreetCommand command = new GreetCommand();
command.name = "Bob";
command.capitalized = true;
command.repeat = 1;
int exitCode = command.call();
assertEquals(0, exitCode);
}
@Test
void shouldRepeatGreeting() {
GreetCommand command = new GreetCommand();
command.name = "Charlie";
command.capitalized = false;
command.repeat = 3;
int exitCode = command.call();
assertEquals(0, exitCode);
}
}Integration Testing with CommandLine
@Test
void shouldParseArguments() {
GreetCommand command = new GreetCommand();
CommandLine cmd = new CommandLine(command);
int exitCode = cmd.execute("Alice", "--capitalized", "--repeat", "2");
assertEquals(0, exitCode);
assertEquals("Alice", command.name);
assertTrue(command.capitalized);
assertEquals(2, command.repeat);
}
@Test
void shouldShowHelpText() {
StringWriter sw = new StringWriter();
CommandLine cmd = new CommandLine(new GreetCommand());
cmd.setOut(new PrintWriter(sw));
int exitCode = cmd.execute("--help");
assertEquals(0, exitCode);
assertTrue(sw.toString().contains("Greets users"));
}
@Test
void shouldFailOnMissingRequiredArgument() {
CommandLine cmd = new CommandLine(new GreetCommand());
int exitCode = cmd.execute(); // Missing name parameter
assertEquals(2, exitCode); // Picocli returns 2 for usage errors
}Summary
Building CLI applications in Java requires robust argument parsing, clear help text, proper error handling, and standard exit codes. Picocli simplifies CLI development through annotations that declare parameters, options, and validation rules. The framework automatically generates help text, validates inputs, and handles common CLI patterns.
Command structure uses @Command annotation for configuration, @Parameters for positional arguments, and @Option for named options. Implement Callable
Subcommands organize complex CLIs into logical groups. Main command declares subcommands, each subcommand implements its own logic. Users run app subcommand --options, mirroring git-style interfaces. Each subcommand has independent parameters, options, and help text.
Exit codes communicate success or failure to shell scripts and calling processes. Use standard conventions - 0 for success, 1 for general errors, 2 for invalid arguments, specific codes for domain errors. Shell scripts check exit codes to determine if operations succeeded.
stdin/stdout/stderr serve different purposes. stdout carries program output suitable for piping to other commands. stderr carries diagnostic messages and errors visible to users but not piped. Reading from stdin enables CLI tools to work in pipelines - cat file | filter | process.
JAR packaging with maven-shade-plugin creates fat JARs containing all dependencies. Single JAR file runs anywhere with Java installed. Shell script wrappers make JARs feel like native commands. GraalVM native-image compiles to native binaries with instant startup and no JVM requirement.
Testing CLI applications involves unit testing command logic and integration testing argument parsing. Test that commands parse arguments correctly, validate inputs, and return appropriate exit codes. Capture stdout/stderr to verify output messages.
Professional CLI applications follow Unix conventions - help text with –help, version with –version, exit codes indicating success/failure, stdin/stdout/stderr for proper I/O, and clear error messages. Picocli handles most conventions automatically, letting you focus on business logic.