Lesson 48 • Expert
Building Command-Line Tools in Java
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.
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 + "!");
}
}
}$ java Greet Alice 3
Hello, Alice!
Hello, Alice!
Hello, Alice!
$ java Greet
Hello, World!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);
}
}$ printf "apple\nbanana\ncherry\n" | java Echo
Your name: apple
Hi, apple!
1: banana
2: cherry
Total lines: 23️⃣ 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.
System.out, error messages go to System.err. That separation lets a user redirect results to a file (>) while still seeing errors on screen.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)
}
}$ java Validate
HOME = /home/alice
env = dev
Error: expected a filename
$ echo $?
2
$ java -Denv=prod Validate report.csv
HOME = /home/alice
env = prod
Processing report.csv...
$ echo $?
0🎯 Your Turn #1 — Word Counter
___ blanks, then run it at onecompiler.com/java (set the command-line arguments in the input box).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
}
}$ java WordCount "the quick brown fox"
Words: 4🎯 Your Turn #2 — Sum Numbers from stdin
___ blanks (the end-of-input sentinel, and the line you just read).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
}
}$ printf "10\n20\n30\n" | java SumLines
Sum: 604️⃣ 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);
}
}picocli on the classpath (add it with Maven/Gradle). Then run java -cp picocli.jar:. Greet Alice --count 3 --upper in your terminal. Full guide at picocli.info.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 3
For 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 withif (args.length == 0) { ...System.exit(2); }before touchingargs[0]. - ❌ No argument validation —
Integer.parseInt(args[0])throwsNumberFormatExceptionwhen the user typesabc. Fix: wrap parsing in try/catch and print a clear usage message toSystem.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: callSystem.exit(1)(or2for usage errors) on every failure path. - ❌ Errors disappear when output is redirected — you printed errors with
System.out.println, sotool > out.txtswallows them. Fix: send errors toSystem.err.println, results toSystem.out. - ❌ Reinventing argument parsing — 100 lines of brittle
if (a.equals("--name"))logic that mishandles edge cases. Fix: use picocli's@Option/@Parametersannotations; 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'smixinStandardHelpOptions = truewires it up automatically. - 💡 Pick stable exit codes —
0success,1general error,2usage 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 |
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.
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.
Next up: Clean Architecture — designing maintainable, testable applications with SOLID and DDD.
Sign up for free to track which lessons you've completed and get learning reminders.