CLI Tools
Reviewed & published by Brayan K
Turn a Java class into a real command-line tool — read arguments and stdin, return exit codes, use environment variables, and graduate to picocli and a runnable jar.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Read arguments from public static void main(String[] args)
- Read input from stdin with Scanner and BufferedReader
- Return exit codes with System.exit so scripts can react
- Read environment variables and -D system properties
- Build a real CLI with picocli: options and subcommands
- Package your tool as a runnable jar you can ship
Before You Start
You should be comfortable with methods and arrays, and have seen exception handling (parsing text can throw). To package the final tool, the Deployment lesson covers building a jar.
A Real-World Analogy
Think of a command-line tool as a kitchen appliance. The arguments you type are the dials you set before turning it on (--count 3). Stdin is the food you feed in through the chute. The result comes out one spout (stdout) while error noises come out another (stderr). And when it finishes, a light shows green for success or red for a jam — that light is the exit code.
You can wire all of that by hand with raw args[] — like building the appliance from loose parts. picocli is the pre-built appliance: you label the dials with annotations and it handles parsing, validation, and the --help manual for you.
1️⃣ The Entry Point: main(String[] args)
Every Java program starts at one method: public static void main(String[] args). The args array holds whatever words you typed after the program name. If you run java Greet Alice 3, then args[0] is "Alice" and args[1] is "3".
Two things trip everyone up. First, every argument arrives as a String — "3" is text, so convert it with Integer.parseInt before doing maths. Second, args can be empty, so always check args.length before reading args[0].
public class Greet {
// args[] holds the words you typed after the program name.
// Run as: java Greet Alice 3
// -> args[0] = "Alice", args[1] = "3"
public static void main(String[] args) {
// Defaults so the tool still works with no arguments.
String name = "World";
int times = 1;
// args.length tells you how many were passed — ALWAYS check it.
if (args.length >= 1) {
name = args[0]; // first word after the program name
}
if (args.length >= 2) {
times = Integer.parseInt(args[1]); // text "3" -> int 3
}
for (int i = 0; i < times; i++) {
System.out.println("Hello, " + name + "!");
}
}
}2️⃣ Reading Input from stdin
Stdin ("standard input") is the stream of text a user types — or that another program pipes in with |. Two classes read it. Scanner is friendly for interactive prompts (nextLine, nextInt). BufferedReader is faster and ideal when lots of lines are piped in.
The key pattern: readLine() returns the next line, or null when input ends (the user presses Ctrl-D, or the pipe finishes). Looping while ((line = in.readLine()) != null) processes every line and stops cleanly.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Scanner;
public class Echo {
public static void main(String[] args) throws Exception {
// --- Scanner: easy, great for prompting interactively ---
Scanner sc = new Scanner(System.in);
System.out.print("Your name: "); // print (no newline) so the prompt sits inline
String name = sc.nextLine(); // reads one line the user types
System.out.println("Hi, " + name + "!");
// --- BufferedReader: faster, ideal for piping lots of lines in ---
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
String line;
int count = 0;
// readLine() returns null at end-of-input (Ctrl-D / end of a pipe).
while ((line = in.readLine()) != null) {
count++;
System.out.println(count + ": " + line); // number each line, like 'cat -n'
}
System.out.println("Total lines: " + count);
}
}3️⃣ Exit Codes, Environment Variables & System Properties
When your tool finishes, it returns an exit code to the shell: 0 means success, any non-zero number means failure. Scripts read it (the shell stores it in $?) to decide whether to keep going. Set it with System.exit(code), and send error text to System.err, not System.out.
Configuration comes from two places. Environment variables are set by the shell before your program runs — read them with System.getenv("HOME") (returns null if unset). System properties are JVM settings you pass with -D, like java -Denv=prod App, read with System.getProperty("env", "dev") where "dev" is the fallback.
public class Validate {
public static void main(String[] args) {
// --- Environment variables: set OUTSIDE the program (the shell) ---
// System.getenv returns null if the variable is not set.
String home = System.getenv("HOME");
System.out.println("HOME = " + home);
// --- System properties: passed with -D or built into the JVM ---
// java -Denv=prod Validate -> System.getProperty("env") = "prod"
String env = System.getProperty("env", "dev"); // "dev" is the fallback
System.out.println("env = " + env);
// --- Exit codes: 0 = success, non-zero = failure ---
if (args.length == 0) {
System.err.println("Error: expected a filename"); // errors go to stderr
System.exit(2); // stops now; the shell sees code 2 (a usage error)
}
System.out.println("Processing " + args[0] + "...");
System.exit(0); // explicit success (0 is also the default)
}
}🎯 Your Turn #1 — Word Counter
public class WordCount {
public static void main(String[] args) {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) If no argument was given, print an error to stderr and exit with code 2.
if (args.length == 0) {
System.err.println("Usage: WordCount <sentence>");
System.exit(___); // 👉 use the conventional usage-error code
}
// 2) split() turns "a b c" into an array of words.
String sentence = args[0];
String[] words = sentence.split(" ");
// 3) Print how many words there are.
System.out.println("Words: " + ___); // 👉 the length of the words array
// ✅ Expected output:
// $ java WordCount "the quick brown fox"
// Words: 4
}
}🎯 Your Turn #2 — Sum Numbers from stdin
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class SumLines {
public static void main(String[] args) throws Exception {
// 🎯 YOUR TURN — read numbers from stdin and add them up
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
int total = 0;
String line;
// 1) Loop until readLine() returns null (end of input).
while ((line = in.readLine()) != ___) { // 👉 what does readLine() return at the end?
// 2) Turn the line of text into an int and add it to total.
total += Integer.parseInt(___); // 👉 the current line you just read
}
System.out.println("Sum: " + total);
// ✅ Expected output:
// $ printf "10\n20\n30\n" | java SumLines
// Sum: 60
}
}4️⃣ Real CLIs with picocli
Hand-rolled args[] parsing works for tiny scripts, but it gets ugly fast: options with values, flags, defaults, type conversion, --help text, and subcommands all become your problem. picocli is a small, popular library that does all of that from annotations.
You annotate fields: @Parameters marks a positional argument (greet Alice), @Option marks a named option (--count 3 or the flag --upper), and mixinStandardHelpOptions = true adds --help and --version for free. Your class implements Callable<Integer>; picocli parses the arguments, converts "3" into an int, runs your call() method, and uses its return value as the exit code.
For bigger tools, picocli also supports subcommands — @Command(subcommands = { UserAdd.class, UserList.class }) gives you a git-style mytool user add / mytool user list hierarchy, each with its own options and help.
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;
@Command(name = "greet", version = "greet 1.0",
description = "Greet someone N times.",
mixinStandardHelpOptions = true) // adds --help and --version for free
public class Greet implements Callable<Integer> {
@Parameters(index = "0", description = "Who to greet")
String name; // a positional argument: greet Alice
@Option(names = {"-c", "--count"}, defaultValue = "1",
description = "How many times (default: 1)")
int count; // picocli converts "3" -> int for you
@Option(names = {"-u", "--upper"}, description = "Shout it")
boolean upper; // a flag: present = true, absent = false
@Override
public Integer call() { // runs after parsing succeeds
String msg = "Hello, " + name + "!";
if (upper) msg = msg.toUpperCase();
for (int i = 0; i < count; i++) System.out.println(msg);
return 0; // becomes the process exit code
}
public static void main(String[] args) {
// execute() parses args, runs call(), and returns the exit code.
int code = new CommandLine(new Greet()).execute(args);
System.exit(code);
}
}5️⃣ Packaging a Runnable Jar
To share your tool, bundle it into a runnable jar — a single file whose manifest records which class has main. Anyone with a JDK can then run it without compiling.
# Compile your sources to .class files
javac Greet.java
# Build a jar that knows its entry point (the class with main)
jar --create --file greet.jar --main-class Greet Greet.class
# Run it anywhere — no need to remember the class name
java -jar greet.jar Alice --count 3For an even faster, JVM-free binary, compile the jar with GraalVM's native-image tool — it produces a single executable that starts in milliseconds. That is covered in the Deployment lesson.
Common Errors (and the Fix)
- ❌ ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 — you read args[0] but the user passed no arguments. Fix: guard first with if (args.length == 0) { ...System.exit(2); } before touching args[0].
- ❌ No argument validation — Integer.parseInt(args[0]) throws NumberFormatException when the user types abc. Fix: wrap parsing in try/catch and print a clear usage message to System.err, or let picocli validate the type for you.
- ❌ Tool always "succeeds" in scripts — you printed an error but never set an exit code, so the shell sees 0. Fix: call System.exit(1) (or 2 for usage errors) on every failure path.
- ❌ Errors disappear when output is redirected — you printed errors with System.out.println, so tool > out.txt swallows them. Fix: send errors to System.err.println, results to System.out.
- ❌ Reinventing argument parsing — 100 lines of brittle if (a.equals("--name")) logic that mishandles edge cases. Fix: use picocli's @Option/@Parameters annotations; you get parsing, defaults, --help, and validation for free.
Pro Tips
- 💡 Support - as a filename — the convention "read from stdin" is the single dash -. Many Unix tools follow it.
- 💡 Always add --help — users expect it. picocli's mixinStandardHelpOptions = true wires it up automatically.
- 💡 Pick stable exit codes — 0 success, 1 general error, 2 usage error. Document them so scripts can branch reliably.
- 💡 Generate tab completion — picocli can emit a bash/zsh completion script from your annotated command for a big UX win.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Read an argument | args[0] | Check args.length first! |
| Count arguments | args.length | 0 when none were passed |
| Text → number | Integer.parseInt(s) | Throws on bad input |
| Read a line of stdin | in.readLine() | null at end of input |
| Interactive prompt | new Scanner(System.in) | nextLine(), nextInt() |
| Exit with a code | System.exit(0) | 0 ok, non-zero error |
| Print an error | System.err.println(s) | Not System.out |
| Environment variable | System.getenv("HOME") | null if unset |
| System property | System.getProperty("env","dev") | Pass with -Denv=prod |
| picocli option | @Option(names = {"-c", "--count"}) | Named flag/value |
| picocli positional | @Parameters(index = "0") | Bare argument |
| Build a runnable jar | jar --create --file x.jar --main-class X X.class | Run: java -jar x.jar |
Mini-Challenge — Calculator CLI
public class Calc {
public static void main(String[] args) {
// 🎯 MINI-CHALLENGE: a tiny calculator CLI
// Run it like: java Calc 6 + 4 -> 10
//
// 1. If args.length is not exactly 3, print a usage message to
// System.err and System.exit(2).
// 2. Read args[0] and args[2] as ints (Integer.parseInt).
// 3. Look at args[1] ("+", "-", "*", or "/") and compute the result.
// Use a switch; for an unknown operator, error out with exit code 2.
// 4. Print the result, then System.exit(0).
//
// ✅ Expected:
// $ java Calc 6 + 4 -> 10
// $ java Calc 9 / 3 -> 3
// $ java Calc 6 ^ 4 -> (error to stderr, exit code 2)
// your code here
}
}🎉 Lesson Complete!
You can now build a real Java command-line tool: read args[] from main, pull input from stdin with Scanner or BufferedReader, return meaningful exit codes via System.exit, and read configuration from environment variables and -D system properties. You also know when to graduate from hand-rolled parsing to picocli, and how to package the result as a runnable jar.
Practice quiz
In public static void main(String[] args), what is args[0] when you run 'java Greet Alice 3'?
- "Greet"
- "3"
- "Alice"
- The program name
Answer: "Alice". args holds the words AFTER the program name, so args[0] is "Alice" and args[1] is "3".
Every command-line argument arrives in main as:
- A String
- An int
- An Object
- A char[]
Answer: A String. All args are Strings. Convert text like "3" with Integer.parseInt before doing arithmetic.
Why must you check args.length before reading args[0]?
- args is never empty
- args[0] is always the program name
- It improves performance
- args can have length 0, so args[0] would throw ArrayIndexOutOfBoundsException
Answer: args can have length 0, so args[0] would throw ArrayIndexOutOfBoundsException. If no arguments are passed, args has length 0 and reading args[0] throws ArrayIndexOutOfBoundsException.
BufferedReader.readLine() returns what value at the end of input?
- An empty string
- null
- -1
- It throws EOFException
Answer: null. readLine() returns null at end-of-input, which is why the loop is while ((line = in.readLine()) != null).
By convention, what exit code means SUCCESS?
- 0
- 1
- -1
- 2
Answer: 0. 0 means success; any non-zero value means failure. 2 is commonly a usage/bad-arguments error.
Error messages from a CLI tool should be sent to:
- System.out
- A log file only
- System.err
- System.getenv
Answer: System.err. Errors go to System.err so a user can redirect results (System.out) to a file while still seeing errors.
How do you read an environment variable like HOME?
- System.getProperty("HOME")
- System.getenv("HOME")
- args["HOME"]
- System.env("HOME")
Answer: System.getenv("HOME"). System.getenv("HOME") reads an environment variable (set by the shell); it returns null if unset.
You run 'java -Denv=prod App'. How do you read that value inside the program?
- System.getenv("env")
- args[0]
- System.getDProperty("env")
- System.getProperty("env")
Answer: System.getProperty("env"). A -D flag sets a JVM system property, read with System.getProperty("env") (with an optional fallback).
When should you graduate from hand-parsing args[] to a library like picocli?
- Never — args[] is always enough
- As soon as you need options with values, flags, defaults, --help, or subcommands
- Only for GUI applications
- Only when reading from a file
Answer: As soon as you need options with values, flags, defaults, --help, or subcommands. picocli replaces brittle parsing with annotations and gives you validation, --help, and type conversion for free.
What does Integer.parseInt(args[0]) throw when the user types 'abc'?
- ArrayIndexOutOfBoundsException
- NullPointerException
- NumberFormatException
- Nothing — it returns 0
Answer: NumberFormatException. parseInt throws NumberFormatException on non-numeric text; wrap it in try/catch and print a usage message.
Continue this course
- Previous: Microservices with Spring Boot (Intro)
- Next: Clean Architecture & Enterprise Patterns in Java — Structure enterprise Java apps with Clean Architecture and SOLID principles
- Quick reference: Java cheat sheet
Frequently asked questions
Why does my CLI throw ArrayIndexOutOfBoundsException when I run it with no arguments?
Because args[0] does not exist when args is empty. main always receives a String[] args, but it can have length 0. Always guard with if (args.length >= 1) (or check args.length == 0 up front) before touching args[0]. picocli removes this problem entirely: a missing @Parameters value produces a clean 'Missing required parameter' message instead of a stack trace.
What is the difference between an environment variable and a system property?
An environment variable is set by the shell or operating system before your program starts, and you read it with System.getenv("NAME"). A system property is a JVM-level setting you pass with -D on the command line, like java -Denv=prod App, and you read it with System.getProperty("env"). Use env vars for machine-wide config (PATH, HOME) and -D properties for per-run JVM tweaks.
What exit code should my command-line tool return?
By convention, 0 means success and any non-zero value means failure, so scripts can test it with $? or chain with &&. Common choices: 1 for a general error, 2 for a usage or bad-arguments error. Return the code from main by calling System.exit(code), or with picocli by returning an int from call(); execute() turns it into the exit code for you.
When should I switch from parsing args[] by hand to using picocli?
Parse args[] by hand only for trivial throwaway scripts. As soon as you need options with values, flags, default values, type conversion, --help text, or subcommands, reach for picocli. It replaces ~100 lines of brittle parsing with a few annotations, and gives you validation, colored usage messages, and tab completion for free.
How do I package my CLI so people can run it as a single file?
Compile, then build a runnable JAR whose manifest names the main class: jar --create --file greet.jar --main-class Greet *.class. Users run it with java -jar greet.jar Alice. For a self-contained binary with no JVM startup cost, compile the JAR with GraalVM's native-image, which produces a single executable that starts in milliseconds.