BiblioTech has an architecture, modules, patterns and forty-one tests. And still nobody can use it.

This lesson builds the project's first real interface: the bibliotech-console module. And it is worth disarming a very widespread prejudice from the outset: the console is not "the second-class interface, while we wait for the web". In a company like Nexus Software, the CLI is the interface that gets used the most without anyone seeing it, because it is the only one you can put inside a cron job, chain with other tools, run over SSH on a server with no graphical environment, and call from a deployment script.

In module 2 you built an interactive menu with Scanner and a switch. That was a control-flow exercise. What we are going to build here is something else: a tool with subcommands, typed and validated options, automatically generated help, terminal autocompletion, exit codes that mean something, output in several formats, pipes, colours that switch themselves off when they should, progress bars, clean cancellation with Ctrl+C and an executable jar with its launch script.

The difference between the two boils down to one sentence: the module 2 menu is used by a person; this CLI is used by a person and also by a script. And designing for both at once is what makes it professional.

By the end you will know when a CLI is the right interface, you will have mastered Picocli integrated with Spring Boot, you will design a coherent subcommand hierarchy, you will understand why the separation between standard output and standard error really matters, you will use exit codes that scripts can interpret, you will format output for humans and for machines, you will handle long operations with progress and cancellation, you will package the application and —something almost nobody does— you will test a console application.

Contents

  1. When a CLI is the right interface
  2. What makes a CLI good
  3. Parsing command-line arguments by hand, and its limits
  4. Picocli: the annotation model
  5. Options, parameters and types
  6. Validation and type conversion
  7. Subcommands and hierarchy
  8. Automatic help and autocompletion
  9. Integration with Spring Boot
  10. Designing BiblioTech's CLI
  11. Single-command mode versus interactive mode
  12. Standard input and output: composing with pipes
  13. Exit codes
  14. Formatting the output: table, CSV and JSON
  15. Verbosity: --quiet and --verbose
  16. Long operations: progress and signs of life
  17. Clean cancellation with Ctrl+C
  18. ANSI colours and when to switch them off
  19. User-oriented errors
  20. Packaging and distribution
  21. Testing a console application
  22. Common Mistakes and Tips
  23. Exercises
  24. Conclusion

  1. When a CLI is the right interface

Situation CLI? Why
Nightly scheduled task (due-date notices) Yes cron cannot press buttons
Bulk catalogue import Yes It runs over SSH, with no graphical environment, and the output can be redirected to a file
Internal tool for the technical team Yes Quick to write, quick to use, composable
A step in a deployment pipeline Yes Exit codes that the pipeline interprets
Diagnosis in production at 3 in the morning Yes It is the only thing there is on a server
Catalogue lookups by any employee No They need to browse visually; web
Registering a material with fifteen fields No A form with live validation
A metrics dashboard No Charts

The useful rule: a CLI wins when the task is repeatable, automatable, or run by someone technical. It loses when the task is exploratory or run by someone who does not live in a terminal.

And the two coexist perfectly well. BiblioTech will have a CLI (this lesson) and a REST API (12-04), both sharing exactly the same use cases from the bibliotech-application module. That is the payoff of the architecture from 12-01: two inbound adapters, a single core.

  1. What makes a CLI good

Four properties, and none of them is optional:

Predictable. The same conventions as the rest of the system's tools: -v and --verbose, --help, --version, noun before verb or the other way round, but always the same. A CLI that invents its own syntax forces you to read the documentation every time.

Composable. It reads from standard input, writes to standard output, and diagnostics go to standard error. That allows:

bibliotech catalog list --format=csv | grep "Java" | wc -l
cat isbns.txt | bibliotech catalog import --from-stdin
bibliotech report fines --format=json | jq '.[] | select(.amount > 10)'

Good at error messages. An error must say what happened, why and what to do. Compare:

Error: NullPointerException

with:

Error: no material found with ISBN 978-0000000009.

  Cause: the ISBN does not exist in the catalogue.
  Suggestion: check the ISBN with 'bibliotech catalog search --title="..."'
              or import it with 'bibliotech catalog import'.

Honest about state. If it is going to take a while, it says so. If it is going to modify data, it warns you. If it has a simulation mode (--dry-run), it offers it for destructive operations.

  1. Parsing command-line arguments by hand, and its limits

Java hands you the arguments in String[] args. Parsing them by hand looks trivial:

public static void main(String[] args) {
    String format = "table";
    boolean verbose = false;
    String isbn = null;

    for (int i = 0; i < args.length; i++) {
        switch (args[i]) {
            case "--format" -> format = args[++i];
            case "-v", "--verbose" -> verbose = true;
            case "--isbn" -> isbn = args[++i];
            default -> {
                System.err.println("Unknown option: " + args[i]);
                System.exit(2);
            }
        }
    }
    // …
}

It works for three options. Now the list of what it does not do, and that a terminal user expects to work:

Missing Example that fails
The --option=value form --format=json is read as an unknown option
Grouped short options -vq instead of -v -q
-- to separate options from arguments bibliotech search -- --weird-title
Type conversion Everything is a String; convert and validate by hand
Required options Nothing checks that --isbn is present
Exclusive groups --format=json --format=csv raises no error
Help You have to write it and keep it in sync by hand
Index out of bounds --format at the end: ArrayIndexOutOfBoundsException
Subcommands catalog list needs another level of parsing
Autocompletion Impossible

That last ++i without a bounds check is a real bug waiting for someone to type bibliotech list --format and press Enter.

Conclusion: parsing by hand is fine for a twenty-line script. For a real tool, you use a library. In Java there are three serious candidates:

Library Advantages Drawbacks
Picocli Annotations, subcommands, colours, autocompletion, no dependencies, GraalVM support
Apache Commons CLI Very stable, long-established Imperative and verbose API; no native subcommands
JCommander Simple, annotation-based Less active; fewer features

We will use Picocli: it is the de facto standard choice in modern Java, and it is the one Spring Boot integrates with an official starter.

  1. Picocli: the annotation model

The dependency in bibliotech-console/pom.xml:

<dependencies>
  <dependency>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech-application</artifactId>
  </dependency>
  <dependency>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech-infrastructure</artifactId>
  </dependency>

  <dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli-spring-boot-starter</artifactId>   <!-- brings picocli + integration -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>           <!-- no 'web': this is not a server -->
  </dependency>
</dependencies>

Picocli's "hello world", with all the essentials:

package com.nexussoftware.bibliotech.console;

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;

@Command(
    name = "bibliotech",                          // how it is invoked
    mixinStandardHelpOptions = true,              // adds --help and --version for free
    version = "BiblioTech CLI 1.0.0",
    description = "Management tool for Nexus Software's technical library.")
public class Example implements Callable<Integer> {   // Callable<Integer>: the int is the exit code

    @Parameters(index = "0", description = "ISBN of the material to look up.")
    private String isbn;

    @Option(names = {"-f", "--format"}, defaultValue = "table",
            description = "Output format: ${COMPLETION-CANDIDATES}. Default: ${DEFAULT-VALUE}")
    private Format format;

    @Override
    public Integer call() {
        System.out.println("Looking up " + isbn + " in format " + format);
        return 0;                                  // 0 = success
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new Example()).execute(args);
        System.exit(exitCode);
    }
}

With those twenty lines you already have:

$ java -jar example.jar --help
Usage: bibliotech [-hV] [-f=<format>] <isbn>
Management tool for Nexus Software's technical library.
      <isbn>          ISBN of the material to look up.
  -f, --format=<format>
                      Output format: TABLE, CSV, JSON. Default: TABLE
  -h, --help          Show this help message and exit.
  -V, --version       Print version information and exit.

The three elements of the model:

Annotation What it marks Example on the command line
@Command The class (or method) that is a command bibliotech
@Option A named argument --format=json, -v
@Parameters A positional argument the 978-0000000001 in bibliotech catalog card 978-0000000001

  1. Options, parameters and types

Picocli converts to more than 40 types automatically, including those from java.time (module 10):

@Command(name = "loan")
class ExampleOptions {

    // Boolean: its mere presence sets it to true
    @Option(names = {"-v", "--verbose"}, description = "Shows execution detail.")
    boolean verbose;

    // Required: if it is missing, Picocli produces a clear error
    @Option(names = "--isbn", required = true, description = "ISBN of the material.")
    String isbn;

    // With a default value
    @Option(names = "--days", defaultValue = "15", description = "Duration in days.")
    int days;

    // A java.time type: automatic conversion from ISO-8601
    @Option(names = "--from", description = "Start date (yyyy-MM-dd).")
    LocalDate from;

    // Enum: Picocli validates the allowed values and lists them in the help
    @Option(names = "--format", defaultValue = "TABLE")
    Format format;

    // Repeatable: --tag java --tag design
    @Option(names = "--tag", description = "Tag (repeatable).")
    List<String> tags = new ArrayList<>();

    // With arity: exactly two values
    @Option(names = "--range", arity = "2", paramLabel = "<from> <to>")
    int[] range;

    // Map: -D key=value
    @Option(names = "-D", description = "Additional property.")
    Map<String, String> properties = new LinkedHashMap<>();

    // Sensitive: Picocli asks for it at the keyboard without echoing it if it is not passed
    @Option(names = "--key", interactive = true, arity = "0..1")
    char[] key;

    // Positional: the first one
    @Parameters(index = "0", description = "Input file.")
    Path file;

    @Parameters(index = "1..*", description = "Additional ISBNs.")
    List<String> extraIsbns;
}

About interactive = true and char[]: this is the correct way to ask for a password. char[] instead of String so that you can overwrite it in memory after using it; a String stays in the pool until the collector reclaims it, and it can show up in a memory dump (picked up again in 12-07).

Exclusive groups, for options that are incompatible with each other:

static class DataSource {
    @Option(names = "--file", required = true) Path file;
    @Option(names = "--url", required = true) URI url;
    @Option(names = "--stdin", required = true) boolean stdin;
}

@ArgGroup(exclusive = true, multiplicity = "1")   // exactly one of the three
DataSource source;

If the user passes two, Picocli rejects it with a clear message, without you writing a single check.

  1. Validation and type conversion

Picocli validates types; you write the business validation, and there is a right place for it: your own converter, so that the error appears during parsing and not halfway through execution.

/** Converts the command-line text into the domain's value object. */
public class IsbnConverter implements CommandLine.ITypeConverter<Isbn> {

    @Override
    public Isbn convert(String value) {
        try {
            return Isbn.of(value);          // the value object already validates (12-02)
        } catch (InvalidIsbnException e) {
            // TypeConversionException produces a usage message, not a stack trace
            throw new CommandLine.TypeConversionException(
                "'" + value + "' is not a valid ISBN-13. Expected format: 978-XXXXXXXXXX");
        }
    }
}
@Option(names = "--isbn", required = true, converter = IsbnConverter.class)
private Isbn isbn;      // the field is of the domain type, not String!

The result in the terminal:

$ bibliotech loan create --isbn=1234 --employee=1
Invalid value for option '--isbn': '1234' is not a valid ISBN-13.
Expected format: 978-XXXXXXXXXX
Usage: bibliotech loan create [-hV] --employee=<id> --isbn=<isbn> [--days=<n>]
…

For rules that depend on several options, you use the command specification:

@Spec CommandLine.Model.CommandSpec spec;

private void validate() {
    if (from != null && to != null && from.isAfter(to)) {
        throw new CommandLine.ParameterException(spec.commandLine(),
            "--from (" + from + ") cannot be later than --to (" + to + ")");
    }
}

ParameterException matters: it makes Picocli print the message and the usage, and return the usage-error exit code (2), which is what a script expects for "you called me wrong".

  1. Subcommands and hierarchy

A CLI with more than five operations needs subcommands. The pattern that has won in the industry is noun + verb (git remote add, docker container run, kubectl get pods), because it groups by area and scales better.

flowchart TD
    R["bibliotech"]
    C["catalog"]
    P["loan"]
    A["notices"]
    I["report"]

    R --> C
    R --> P
    R --> A
    R --> I

    C --> C1["list"]
    C --> C2["search"]
    C --> C3["import"]
    C --> C4["card"]
    P --> P1["create"]
    P --> P2["return"]
    P --> P3["renew"]
    P --> P4["list"]
    A --> A1["send"]
    A --> A2["preview"]
    I --> I1["fines"]
    I --> I2["usage"]

The root command declares its children:

@Command(
    name = "bibliotech",
    mixinStandardHelpOptions = true,
    version = "BiblioTech CLI 1.0.0",
    description = "Management of Nexus Software's technical library.",
    subcommands = {
        CatalogCommand.class,
        LoanCommand.class,
        NoticesCommand.class,
        ReportCommand.class,
        CommandLine.HelpCommand.class          // 'bibliotech help catalog'
    })
@Component
public class RootCommand implements Callable<Integer> {

    @Override
    public Integer call() {
        // With no subcommand, show the help and return a usage error:
        // that way a script knows the invocation was incomplete.
        CommandLine.usage(this, System.out);
        return ExitCode.USAGE_ERROR;
    }
}

And an intermediate command, which only groups:

@Command(name = "catalog",
         description = "Operations on the catalogue of materials.",
         subcommands = {CatalogListCommand.class, CatalogSearchCommand.class,
                        CatalogImportCommand.class, CatalogCardCommand.class})
@Component
public class CatalogCommand implements Callable<Integer> {
    @Override public Integer call() {
        CommandLine.usage(this, System.out);
        return ExitCode.USAGE_ERROR;
    }
}

Inherited options. The global ones (--format, --verbose) are declared once with scope = ScopeType.INHERIT:

@Command(name = "bibliotech", …)
public class RootCommand {

    @Option(names = {"-f", "--format"}, scope = CommandLine.ScopeType.INHERIT,
            defaultValue = "TABLE", description = "Output format: ${COMPLETION-CANDIDATES}")
    Format format;

    @Option(names = {"-q", "--quiet"}, scope = CommandLine.ScopeType.INHERIT,
            description = "Errors only.")
    boolean quiet;

    @Option(names = {"-v", "--verbose"}, scope = CommandLine.ScopeType.INHERIT,
            description = "Execution detail.")
    boolean verbose;
}

Now bibliotech catalog list --format=json works without declaring --format in every subcommand.

  1. Automatic help and autocompletion

mixinStandardHelpOptions = true generates --help and --version. The quality of that help depends on what you write in description, and there are useful variables:

Variable Replaced by
${DEFAULT-VALUE} The option's default value
${COMPLETION-CANDIDATES} The valid values (useful with an enum)
${sys:user} A system property

And you can add whole sections, which is what separates useful help from a list of options:

@Command(name = "import",
    description = "Imports materials into the catalogue from a file or from standard input.",
    footerHeading = "%nExamples:%n",
    footer = {
        "  bibliotech catalog import catalog.csv",
        "  bibliotech catalog import --input-format=json data.json --dry-run",
        "  cat isbns.txt | bibliotech catalog import --from-stdin --enrich",
        "",
        "Exit codes: 0 success, 1 error, 2 wrong usage, 4 partial import."
    })
class CatalogImportCommand implements Callable<Integer> { … }

Autocompletion. Picocli generates a completion script for bash and zsh:

# Generate the script (once, at build time)
java -cp bibliotech-console.jar picocli.AutoComplete \
     -n bibliotech com.nexussoftware.bibliotech.console.RootCommand

# Enable it in the session
source bibliotech_completion

# Now the tab key works
$ bibliotech cat<TAB>          → bibliotech catalog
$ bibliotech catalog <TAB>     → card  import  list  search
$ bibliotech catalog list --format=<TAB>  → TABLE  CSV  JSON

This is one of the details most appreciated by the people who use the tool every day, and it costs one line in the POM.

  1. Integration with Spring Boot

The problem to solve: the commands need the application services (ManageLoans, QueryCatalog), which are Spring beans. But Picocli instantiates commands by reflection. Without integration, you would end up with new LoanCommand(context.getBean(...)), which is Service Locator, an anti-pattern (12-02).

picocli-spring-boot-starter solves it with a factory that delegates to the context:

package com.nexussoftware.bibliotech.console;

@SpringBootApplication(scanBasePackages = "com.nexussoftware.bibliotech")
public class BiblioTechCli implements CommandLineRunner, ExitCodeGenerator {

    private final RootCommand rootCommand;
    private final CommandLine.IFactory factory;    // supplied by the Picocli starter
    private int exitCode;

    public BiblioTechCli(RootCommand rootCommand, CommandLine.IFactory factory) {
        this.rootCommand = rootCommand;
        this.factory = factory;
    }

    @Override
    public void run(String... args) {
        this.exitCode = new CommandLine(rootCommand, factory)
                .setCaseInsensitiveEnumValuesAllowed(true)     // --format=json and JSON
                .setExecutionExceptionHandler(new CliErrorHandler())
                .execute(args);
    }

    @Override
    public int getExitCode() { return exitCode; }

    public static void main(String[] args) {
        // SpringApplication.exit returns the ExitCodeGenerator's code,
        // and closes the context in an orderly way before exiting.
        System.exit(SpringApplication.exit(SpringApplication.run(BiblioTechCli.class, args)));
    }
}

With this, the commands are ordinary beans and receive their dependencies through the constructor:

@Command(name = "create", description = "Creates a loan of a material to an employee.")
@Component
public class LoanCreateCommand implements Callable<Integer> {

    private final ManageLoans manager;            // constructor injection, as always!
    private final Output output;

    public LoanCreateCommand(ManageLoans manager, Output output) {
        this.manager = manager;
        this.output = output;
    }

    @Option(names = "--isbn", required = true, converter = IsbnConverter.class)
    private Isbn isbn;

    @Option(names = "--employee", required = true, paramLabel = "<id>")
    private Long employeeId;

    @Option(names = "--days", description = "Duration. Defaults to the material type's.")
    private Integer days;

    @Override
    public Integer call() {
        Loan loan = manager.lend(isbn, employeeId, days);
        output.success("Loan #%d created. Due on %s."
                .formatted(loan.getId(), loan.getDueDate()));
        return ExitCode.OK;
    }
}

An important configuration detail. The CLI must not start a web server or print the Spring banner. In bibliotech-console/src/main/resources/application.yml:

spring:
  main:
    web-application-type: none      # no Tomcat
    banner-mode: off                # no ASCII banner polluting the output
  output:
    ansi:
      enabled: detect               # colours only if the terminal supports them

logging:
  pattern:
    console: "%d{HH:mm:ss} %-5level %msg%n"
  level:
    root: WARN                      # a quiet CLI by default
    com.nexussoftware.bibliotech: INFO

banner-mode being off is not cosmetic: if the output is going to be chained into jq, the banner breaks the JSON.

  1. Designing BiblioTech's CLI

The tool's complete surface:

Command Description Main options
catalog list Lists materials --type, --available, --limit, --sort-by
catalog search Searches by criteria --title, --author, --from, --to
catalog card Detail of one material <isbn> positional, --with-history
catalog import Imports from a file or stdin --from-stdin, --input-format, --dry-run, --enrich, --threads
loan create Creates a loan --isbn, --employee, --days
loan return Records a return <loanId>, --date
loan renew Renews a loan <loanId>, --days
loan list Lists loans --employee, --status, --overdue
notices send Sends due-date notices --days-ahead, --dry-run
report fines Fines report --from, --to, --employee
report usage Monthly usage report --period

Global options, available everywhere:

Option Effect
-f, --format=<TABLE|CSV|JSON> Output format
-q, --quiet Errors only; no headers or progress messages
-v, --verbose Execution detail (repeatable: -vv for traces)
--no-colour Disables ANSI colours
-h, --help / -V, --version Help and version

A complete command, with everything we have seen so far put together:

@Command(name = "list",
    description = "Lists the materials in the catalogue.",
    footerHeading = "%nExamples:%n",
    footer = {
        "  bibliotech catalog list --type=BOOK --available",
        "  bibliotech catalog list --format=csv > catalog.csv",
        "  bibliotech catalog list --format=json | jq '.[].title'"
    })
@Component
public class CatalogListCommand implements Callable<Integer> {

    private final QueryCatalog catalog;
    private final Output output;

    public CatalogListCommand(QueryCatalog catalog, Output output) {
        this.catalog = catalog;
        this.output = output;
    }

    @Option(names = "--type", description = "Filter by type: ${COMPLETION-CANDIDATES}")
    private MaterialType type;

    @Option(names = "--available", description = "Only materials with free copies.")
    private boolean onlyAvailable;

    @Option(names = "--limit", defaultValue = "50",
            description = "Maximum number of results. Default: ${DEFAULT-VALUE}")
    private int limit;

    @Option(names = "--sort-by", defaultValue = "TITLE",
            description = "Sort criterion: ${COMPLETION-CANDIDATES}")
    private SortCriterion sort;

    @Override
    public Integer call() {
        var criteria = SearchCriteria.builder()            // Builder from 12-02
                .type(type)
                .onlyAvailable(onlyAvailable)
                .build();

        List<Material> results = catalog.search(criteria, sort, limit);

        if (results.isEmpty()) {
            output.warn("No material matches those criteria.");
            return ExitCode.NO_RESULTS;                    // 3: not the same as an error
        }

        output.writeMaterials(results);                    // Output decides the format
        output.info("%d materials.".formatted(results.size()));
        return ExitCode.OK;
    }
}

Notice that the command prints nothing directly. It delegates to Output, which is the one that knows about formats, colours, verbosity and which stream each thing goes to. It is single responsibility applied to the console.

  1. Single-command mode versus interactive mode

Aspect Single command Interactive (REPL)
Invocation bibliotech loan create --isbn=… bibliotech shell, and then commands
Automatable Yes No
Composable with pipes Yes No
Cost per operation A JVM start each time (~1 s) Only the first one
Suitable for Scripts, cron, CI Exploration, many operations in a row

They can coexist, and the key to coexisting well is that the interactive mode must not duplicate logic: it simply reads a line, splits it and hands it to the same CommandLine.

@Command(name = "shell", description = "Interactive mode. Type 'exit' to finish.")
@Component
public class ShellCommand implements Callable<Integer> {

    private final RootCommand root;
    private final CommandLine.IFactory factory;

    @Override
    public Integer call() {
        // Console is null if input is redirected: then the shell makes no sense
        Console console = System.console();
        if (console == null) {
            System.err.println("Interactive mode requires a terminal.");
            return ExitCode.USAGE_ERROR;
        }

        System.out.println("BiblioTech 1.0.0 — type 'help' or 'exit'.");
        CommandLine cl = new CommandLine(root, factory);

        while (true) {
            String line = console.readLine("bibliotech> ");
            if (line == null || line.isBlank()) continue;
            if (line.equals("exit") || line.equals("quit")) return ExitCode.OK;
            if (line.equals("help")) { cl.usage(System.out); continue; }

            // Reuses EXACTLY the same parsing and the same commands
            cl.execute(split(line));
        }
    }

    /** Splits while respecting quotes: search --title="Effective Java" */
    private String[] split(String line) {
        List<String> parts = new ArrayList<>();
        Matcher m = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(line);
        while (m.find()) {
            parts.add(m.group(1) != null ? m.group(1) : m.group(2));
        }
        return parts.toArray(String[]::new);
    }
}

For a serious REPL (history, line editing, live autocompletion), the library is JLine, which Picocli also integrates officially. Here the above is enough.

  1. Standard input and output: composing with pipes

This picks up 01-06 and 06-07, and it is what turns a CLI into a part of the system instead of an island.

The three streams, and the rule for using them:

Stream Java What goes here Redirected with
stdin (0) System.in Input data < or |
stdout (1) System.out The result, and only the result > or |
stderr (2) System.err Diagnostics: warnings, progress, errors 2>

The golden rule: if something is not part of the result, it does not go to System.out. A progress message, a decorative header or a "Processing…" on standard output break | jq and > file.csv.

Reading from standard input:

@Option(names = "--from-stdin", description = "Reads the ISBNs from standard input, one per line.")
private boolean fromStdin;

@Parameters(index = "0", arity = "0..1", description = "Input file.")
private Path file;

private List<String> readInput() throws IOException {
    if (fromStdin) {
        // Mind the encoding: in Java 18+ the default is UTF-8, but being explicit does no harm
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
            return reader.lines()
                    .map(String::strip)
                    .filter(l -> !l.isEmpty() && !l.startsWith("#"))   // ignore comments
                    .toList();
        }
    }
    if (file != null) {
        return Files.readAllLines(file, StandardCharsets.UTF_8);
    }
    throw new CommandLine.ParameterException(spec.commandLine(),
        "Give an input file or use --from-stdin.");
}

Detecting whether the output is a terminal. This governs colours, progress and headers:

/** true if stdout goes to a terminal; false if it goes to a file or to another process. */
public static boolean isTerminal() {
    return System.console() != null;
}

With that, the CLI adapts by itself:

$ bibliotech catalog list                         # terminal: colours, headers, totals
$ bibliotech catalog list > catalog.txt           # file: no colours, no decoration
$ bibliotech catalog list | grep Java             # pipe: the same

And the practical result of having respected the golden rule:

# The result goes to the file; the warnings are still visible on screen
bibliotech catalog import data.csv > result.json 2> import.log

# Chaining without anything getting polluted
bibliotech report fines --format=json | jq '[.[] | select(.amount > 10)] | length'

# Using one command's output as another one's input
bibliotech loan list --overdue --format=csv | cut -d';' -f2 | \
  bibliotech notices send --from-stdin

  1. Exit codes

Every process returns an integer to the operating system. For a person it is invisible; for a script it is everything:

bibliotech notices send || echo "FAILED: check the log"     # || runs if the code != 0

BiblioTech's convention:

Code Constant Meaning Typical script reaction
0 OK Success Carry on
1 ERROR General execution error Abort and alert
2 USAGE_ERROR Invalid arguments Fix the invocation
3 NO_RESULTS Ran correctly, but there was nothing Carry on, no alarm
4 PARTIAL Finished with partial errors Review the detail
5 NOT_FOUND The requested resource does not exist Depends
6 CONFLICT Business rule violated Do not retry
7 UNAVAILABLE External dependency down Retry later
130 Interrupted with Ctrl+C POSIX convention: 128 + SIGINT(2)
public final class ExitCode {
    public static final int OK = 0;
    public static final int ERROR = 1;
    public static final int USAGE_ERROR = 2;
    public static final int NO_RESULTS = 3;
    public static final int PARTIAL = 4;
    public static final int NOT_FOUND = 5;
    public static final int CONFLICT = 6;
    public static final int UNAVAILABLE = 7;
    public static final int INTERRUPTED = 130;

    private ExitCode() { }
}

The distinction between 6 and 7 is the one that adds the most value in practice: a retry script must retry on UNAVAILABLE (the metadata API is down) and not on CONFLICT (the employee already has three loans: retrying a thousand times will not fix it).

#!/usr/bin/env bash
# Retry only when it makes sense
for attempt in 1 2 3; do
  bibliotech catalog import --enrich data.csv
  code=$?
  case $code in
    0) echo "Import successful"; exit 0 ;;
    7) echo "Service unavailable; retry $attempt"; sleep $((attempt * 30)) ;;
    *) echo "Unrecoverable error (code $code)"; exit $code ;;
  esac
done
exit 7

  1. Formatting the output: table, CSV and JSON

The Output class centralises everything to do with presentation. It is a facade (12-02) over format, colour and verbosity.

@Component
public class Output {

    private final ObjectMapper json;         // Jackson, from module 11: a single bean
    private final PrintStream out;
    private final PrintStream err;

    private Format format = Format.TABLE;
    private Level level = Level.NORMAL;
    private boolean colour = true;

    public Output(ObjectMapper json) {
        this.json = json;
        // Explicit encoding: without this, accents break when redirecting on Windows
        this.out = new PrintStream(new FileOutputStream(FileDescriptor.out), true, UTF_8);
        this.err = new PrintStream(new FileOutputStream(FileDescriptor.err), true, UTF_8);
    }

    public void writeMaterials(List<Material> materials) {
        switch (format) {
            case TABLE -> table(materials);
            case CSV   -> csv(materials);
            case JSON  -> json(materials.stream().map(MaterialDto::from).toList());
        }
    }
    // …
}

Table format, with columns that fit the content:

private void table(List<Material> materials) {
    // 1. Work out each column's width: that of the longest content, with a maximum
    int titleWidth = Math.min(45, Math.max(6,
            materials.stream().mapToInt(m -> m.getTitle().length()).max().orElse(6)));

    String rowFormat = "%-17s  %-" + titleWidth + "s  %-8s  %5s%n";

    // 2. Header only if this is NOT a pipe and we are not in quiet mode
    if (showDecoration()) {
        out.printf(rowFormat, "ISBN", "TITLE", "TYPE", "FREE");
        out.println("-".repeat(17 + titleWidth + 8 + 5 + 6));
    }

    // 3. Rows
    for (Material m : materials) {
        out.printf(rowFormat,
                m.getIsbn().value(),
                truncate(m.getTitle(), titleWidth),
                m.type(),
                colourAvailability(m.availableCopies()));
    }
}

/** Truncates with an ellipsis so the table does not fall out of alignment. */
private String truncate(String text, int max) {
    return text.length() <= max ? text : text.substring(0, max - 1) + "…";
}
ISBN               TITLE            TYPE       FREE
---------------------------------------------------
978-0000000001     Effective Java   BOOK          2
978-0000000002     Design Patterns  BOOK          0
978-0000000003     Refactoring      BOOK          1

CSV format, with the escaping that 07-07 taught you not to improvise:

private void csv(List<Material> materials) {
    if (showDecoration()) out.println("isbn;title;type;available");
    for (Material m : materials) {
        out.printf("%s;%s;%s;%d%n",
                m.getIsbn().value(), escape(m.getTitle()), m.type(), m.availableCopies());
    }
}

private String escape(String value) {
    // If it contains the separator, quotes or line breaks, quote it and double the quotes
    if (value.contains(";") || value.contains("\"") || value.contains("\n")) {
        return '"' + value.replace("\"", "\"\"") + '"';
    }
    return value;
}

JSON format, with Jackson:

private void json(Object value) {
    try {
        // Not indented if it is a pipe (more compact); indented if a person is reading it
        ObjectWriter writer = isTerminal()
                ? json.writerWithDefaultPrettyPrinter()
                : json.writer();
        out.println(writer.writeValueAsString(value));
    } catch (JsonProcessingException e) {
        throw new OutputFailedException("Could not serialise the result", e);
    }
}

Usage comparison:

Format For whom When
table People Interactive use (the default)
csv Spreadsheets, cut, awk Reports, importing into Excel
json jq, other programs Automation, integration

  1. Verbosity: --quiet and --verbose

Three levels, and a clear rule about which stream each one goes to:

Level Option What is printed Stream
Quiet -q Only the result and the errors out / err
Normal (none) Result, headers, totals, warnings out / err
Verbose -v Plus intermediate steps and timings err
Trace -vv Plus the application log at DEBUG err
public enum Level { QUIET, NORMAL, VERBOSE, TRACE }

@Component
public class Output {

    /** Result: ALWAYS to stdout, even in quiet mode. It is what the user asked for. */
    public void result(String text) { out.println(text); }

    /** Contextual information: to stderr, so as not to pollute the pipe. */
    public void info(String text) {
        if (level.ordinal() >= Level.NORMAL.ordinal()) err.println(text);
    }

    /** Execution detail: only with -v. */
    public void detail(String text) {
        if (level.ordinal() >= Level.VERBOSE.ordinal()) err.println(grey("  " + text));
    }

    public void warn(String text)    { err.println(yellow("Warning: ") + text); }
    public void error(String text)   { err.println(red("Error: ") + text); }
    public void success(String text) { if (level != Level.QUIET) err.println(green("✓ ") + text); }
}

The level is applied at start-up, and -vv also raises the application's logging level:

@Option(names = {"-v", "--verbose"}, scope = ScopeType.INHERIT)
void setVerbose(boolean[] times) {
    Level level = times.length >= 2 ? Level.TRACE : Level.VERBOSE;
    output.setLevel(level);
    if (level == Level.TRACE) {
        // Raise Logback's level on the fly (11-07)
        ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.nexussoftware.bibliotech"))
            .setLevel(ch.qos.logback.classic.Level.DEBUG);
    }
}

  1. Long operations: progress and signs of life

Importing the catalogue with enrichment queries the metadata API for every material. With 5,000 materials, that is several minutes. Without signs of life, the user assumes it has hung and presses Ctrl+C.

This picks up the concurrent import from module 8: an ExecutorService with virtual threads (10-06), which for I/O-dominated tasks is exactly the ideal use case.

@Command(name = "import", description = "Imports materials into the catalogue.")
@Component
public class CatalogImportCommand implements Callable<Integer> {

    private final CatalogImporter importer;
    private final Output output;

    @Parameters(index = "0", arity = "0..1") private Path file;
    @Option(names = "--from-stdin") private boolean fromStdin;
    @Option(names = "--enrich", description = "Completes the metadata from the external API.")
    private boolean enrich;
    @Option(names = "--dry-run", description = "Writes nothing; shows what it would do.")
    private boolean dryRun;

    @Override
    public Integer call() throws Exception {
        List<ImportRecord> records = readInput();
        output.info("Importing %d records%s…"
                .formatted(records.size(), dryRun ? " (DRY RUN)" : ""));

        var progress = new ProgressBar(records.size(), output);
        var succeeded = new AtomicInteger();                 // module 8
        var errors = Collections.synchronizedList(new ArrayList<ImportError>());

        // Virtual threads: thousands of blocking I/O tasks without exhausting the system
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (ImportRecord record : records) {
                executor.submit(() -> {
                    try {
                        if (!dryRun) importer.importRecord(record, enrich);
                        succeeded.incrementAndGet();
                    } catch (BiblioTechException e) {
                        errors.add(ImportError.from(record, e));
                    } finally {
                        progress.advance();
                    }
                });
            }
        }   // closing the try-with-resources waits for ALL of them to finish

        progress.finish();

        output.writeImportResult(succeeded.get(), errors);

        if (errors.isEmpty()) return ExitCode.OK;
        if (succeeded.get() == 0) return ExitCode.ERROR;
        return ExitCode.PARTIAL;                              // the 4 in the table
    }
}

The progress bar, with the three decisions that make it correct:

public class ProgressBar {

    private static final int WIDTH = 40;

    private final int total;
    private final Output output;
    private final AtomicInteger current = new AtomicInteger();
    private final long start = System.nanoTime();
    private final boolean active;
    private volatile long lastRepaint;

    public ProgressBar(int total, Output output) {
        this.total = total;
        this.output = output;
        // DECISION 1: only if there is a terminal and we are not in quiet mode.
        // A progress bar in a log file is unreadable rubbish.
        this.active = output.isTerminal() && output.level() != Level.QUIET;
    }

    public void advance() {
        int n = current.incrementAndGet();
        if (!active) return;

        // DECISION 2: throttle the repainting. Repainting 5,000 times a second
        // burns more CPU than the work itself.
        long now = System.nanoTime();
        if (n < total && now - lastRepaint < 100_000_000L) return;   // 100 ms
        lastRepaint = now;

        paint(n);
    }

    private void paint(int n) {
        int filled = (int) ((double) n / total * WIDTH);
        long seconds = (System.nanoTime() - start) / 1_000_000_000L;
        long remaining = n > 0 ? seconds * (total - n) / n : 0;

        // DECISION 3: to stderr, not to stdout. Progress is NOT the result.
        // \r goes back to the start of the line without a newline: the bar overwrites itself.
        output.err().printf("\r[%s%s] %d/%d (%d%%) ETA %ds  ",
                "=".repeat(filled), " ".repeat(WIDTH - filled),
                n, total, n * 100 / total, remaining);
    }

    public void finish() {
        if (active) output.err().println();     // close the bar's line
    }
}
[========================>               ] 3120/5000 (62%) ETA 47s

For operations with no known total, a spinner (|, /, -, \) or a simple dot every N items does the same job: saying "I am still alive".

  1. Clean cancellation with Ctrl+C

Ctrl+C sends SIGINT. By default, the JVM terminates immediately: half-finished transactions, half-written files, a Spring context left open.

The solution is a shutdown hook, which picks up what module 7 covered about the orderly closing of resources:

@Component
public class CancellationManager {

    private static final Logger log = LoggerFactory.getLogger(CancellationManager.class);

    private final AtomicBoolean cancelled = new AtomicBoolean(false);
    private final CountDownLatch workFinished = new CountDownLatch(1);

    @PostConstruct
    void register() {
        Runtime.getRuntime().addShutdownHook(new Thread(this::onSignal, "cancellation"));
    }

    private void onSignal() {
        cancelled.set(true);
        System.err.println("\nCancelling… (press Ctrl+C again to force)");
        try {
            // Allow some leeway to finish in an orderly way, but do NOT wait indefinitely:
            // a hook that never ends leaves the process hanging.
            if (!workFinished.await(10, TimeUnit.SECONDS)) {
                System.err.println("The work did not finish in time; exiting anyway.");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** Long loops check this on every iteration. */
    public boolean cancelled() { return cancelled.get(); }

    public void workCompleted() { workFinished.countDown(); }
}

Use inside the command:

@Override
public Integer call() {
    try {
        for (ImportRecord record : records) {
            if (cancellation.cancelled()) {
                output.warn("Import cancelled by the user. "
                          + "Processed %d of %d records.".formatted(processed, records.size()));
                return ExitCode.INTERRUPTED;          // 130, POSIX convention
            }
            importer.importRecord(record, enrich);
            processed++;
        }
        return ExitCode.OK;
    } finally {
        cancellation.workCompleted();        // releases the hook
    }
}

Three shutdown hook rules worth not forgetting:

  1. It must be fast. The system may kill the process if it takes too long (SIGKILL cannot be intercepted).
  2. It cannot depend on the Spring context. It may already be shutting down.
  3. It must be idempotent. A second Ctrl+C must not break anything.

  1. ANSI colours and when to switch them off

Colours are produced with ANSI escape sequences:

public final class Ansi {
    public static final String RESET  = "";
    public static final String RED    = "";
    public static final String GREEN  = "";
    public static final String YELLOW = "";
    public static final String GREY   = "";
    public static final String BOLD   = "";
}

The problem is that, if the output does not go to a terminal, those sequences are written out literally:

$ bibliotech catalog list > output.txt
$ cat output.txt
^[[32m978-0000000001^[[0m  Effective Java …

The decision logic, in order of precedence:

public class ColourDetector {

    public static boolean shouldUseColour(boolean noColourOption) {
        // 1. The user's explicit option wins
        if (noColourOption) return false;

        // 2. NO_COLOR: a universal convention (no-color.org). If it exists, with any value, honour it
        if (System.getenv("NO_COLOR") != null) return false;

        // 3. FORCE_COLOR: to force colours in a CI pipeline that does render them
        if (System.getenv("FORCE_COLOR") != null) return true;

        // 4. A "dumb" terminal (some CI environments, the Emacs shell)
        String term = System.getenv("TERM");
        if ("dumb".equals(term)) return false;

        // 5. No terminal (pipe or redirection): no colour
        return System.console() != null;
    }
}
Condition Colour
--no-colour No
NO_COLOR set No
FORCE_COLOR set Yes
TERM=dumb No
Output redirected or piped No
Interactive terminal Yes

And two tips on using it: colour must never be the only carrier of information (for accessibility and for colour blindness: pair it with a symbol or a word), and less is more — red for errors, yellow for warnings, green for success, grey for the secondary. Nothing else.

  1. User-oriented errors

A stack trace on a user's console is a confession that nobody thought about them. A good error has four parts:

Error: cannot lend "Effective Java" to Diego Alonso.

  Cause:      the employee already has 3 active loans (maximum allowed: 3).
  Suggestion: list their loans with
              bibliotech loan list --employee=2
              and return one before creating a new one.

  Technical detail recorded with id: a7f3e91c

Picocli's centralised handler, which translates module 6's BiblioTechException hierarchy:

public class CliErrorHandler implements CommandLine.IExecutionExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(CliErrorHandler.class);

    @Override
    public int handleExecutionException(Exception e, CommandLine cl, CommandLine.ParseResult parseResult) {

        String incidentId = UUID.randomUUID().toString().substring(0, 8);
        // The FULL technical detail goes to the log, not to the user's screen (06-07)
        log.error("Error while executing the command [id={}]", incidentId, e);

        PrintWriter err = cl.getErr();

        return switch (e) {
            case MaterialNotFoundException ex -> {
                err.println(red("Error: ") + "there is no material with ISBN " + ex.getIsbn() + ".");
                err.println("  Suggestion: look for it with 'bibliotech catalog search --title=\"...\"'");
                yield ExitCode.NOT_FOUND;
            }
            case LoanLimitExceededException ex -> {
                err.println(red("Error: ") + "employee " + ex.getName()
                          + " already has " + ex.getMax() + " active loans.");
                err.println("  Suggestion: 'bibliotech loan list --employee=" + ex.getId() + "'");
                yield ExitCode.CONFLICT;
            }
            case GatewayUnavailableException ex -> {
                err.println(red("Error: ") + "the metadata service is not responding.");
                err.println("  Suggestion: retry later, or use --no-enrich.");
                yield ExitCode.UNAVAILABLE;            // 7: the script SHOULD retry
            }
            case BiblioTechException ex -> {
                err.println(red("Error: ") + ex.getMessage());
                yield ExitCode.ERROR;
            }
            default -> {
                // The unexpected: a generic message + an identifier to correlate with the log
                err.println(red("Unexpected error. ") + "Incident " + incidentId + ".");
                err.println("  Run with -vv to see the detail, or send that identifier to support.");
                yield ExitCode.ERROR;
            }
        };
    }
}

The switch with type patterns is Java 21's pattern matching (10-06), and here it shines especially: it replaces a ladder of instanceof.

The incident identifier is the detail technical support appreciates most: the user sees eight characters, and with them the full stack trace is located in the log (which has already been correlated with MDC since 11-07).

  1. Packaging and distribution

An executable jar with spring-boot-maven-plugin:

<build>
  <finalName>bibliotech-cli</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <configuration>
        <mainClass>com.nexussoftware.bibliotech.console.BiblioTechCli</mainClass>
        <executable>true</executable>   <!-- adds a launch script to the jar itself -->
      </configuration>
      <executions>
        <execution><goals><goal>repackage</goal></goals></execution>
      </executions>
    </plugin>
  </plugins>
</build>
./mvnw -pl bibliotech-console clean package
java -jar bibliotech-console/target/bibliotech-cli.jar catalog list

A launch script, so that it is invoked as bibliotech and not as java -jar …:

#!/usr/bin/env bash
# scripts/bibliotech — install it as /usr/local/bin/bibliotech
set -euo pipefail

BIBLIOTECH_HOME="${BIBLIOTECH_HOME:-/opt/bibliotech}"
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/java}"
JAVA_BIN="${JAVA_BIN:-java}"

# JVM options aimed at fast start-up, not at a long-lived server:
#  -XX:TieredStopAtLevel=1  do not compile deeply: the process lasts seconds
#  -XX:+UseSerialGC         the cheapest GC to initialise
#  -Xshare:auto             use the shared class archive
exec "$JAVA_BIN" \
  -XX:TieredStopAtLevel=1 \
  -XX:+UseSerialGC \
  -Xshare:auto \
  -Dfile.encoding=UTF-8 \
  ${BIBLIOTECH_OPTS:-} \
  -jar "$BIBLIOTECH_HOME/bibliotech-cli.jar" "$@"      # "$@" preserves arguments with spaces

That quoted "$@" matters: without the quotes, bibliotech catalog search --title="Effective Java" breaks into two arguments.

The start-up problem. A CLI with Spring Boot takes between 1 and 3 seconds to start. For occasional use that is tolerable; for a command invoked a thousand times in a loop, it is not.

Option Start-up Cost
Plain jar 1-3 s None
CDS (-XX:SharedArchiveFile) 0.7-2 s An extra build step
AppCDS + -Xshare ~0.6 s The same
jpackage (native installer with the JRE bundled) Same as the jar No Java installation required
GraalVM Native Image ~0.05 s Slow build; reflection has to be declared

jpackage (included in the JDK since Java 14) produces a .deb, .rpm, .msi or .dmg with the JRE inside:

jpackage --type deb \
  --name bibliotech \
  --input bibliotech-console/target \
  --main-jar bibliotech-cli.jar \
  --main-class org.springframework.boot.loader.launch.JarLauncher \
  --app-version 1.0.0 \
  --vendor "Nexus Software"

GraalVM Native Image is the option when instant start-up really matters. Picocli has first-class support (it generates the reflection metadata automatically), and so does Spring Boot 3:

./mvnw -pl bibliotech-console -Pnative native:compile
./bibliotech-console/target/bibliotech catalog list         # starts in ~50 ms

The price: the build takes several minutes, and everything that uses reflection (Jackson, JPA) needs declared metadata. For a small CLI it pays off; for a large application it needs weighing up.

  1. Testing a console application

This is the part almost every project skips, and there is no reason to: a CLI is tested at three levels.

Level 1: the command's logic, without Picocli. The command is an ordinary bean; it is tested with Mockito (11-06):

@ExtendWith(MockitoExtension.class)
class LoanCreateCommandTest {

    @Mock ManageLoans manager;
    @Mock Output output;
    @InjectMocks LoanCreateCommand command;

    @Test
    void returnsOkAndReportsWhenTheLoanIsCreated() {
        var loan = aLoanOf("978-0000000001", "Marta Ruiz");
        when(manager.lend(any(), eq(1L), isNull())).thenReturn(loan);
        ReflectionTestUtils.setField(command, "isbn", Isbn.of("978-0000000001"));
        ReflectionTestUtils.setField(command, "employeeId", 1L);

        Integer exitCode = command.call();

        assertThat(exitCode).isEqualTo(ExitCode.OK);
        verify(output).success(contains("Loan #" + loan.getId()));
    }
}

Level 2: argument parsing. You check that the options are converted correctly, without executing anything:

class ArgumentParsingTest {

    @Test
    void parsesTheOptionsOfTheListCommand() {
        var command = new CatalogListCommand(mock(QueryCatalog.class), mock(Output.class));
        var cl = new CommandLine(command);

        cl.parseArgs("--type=BOOK", "--available", "--limit=10");

        assertThat(ReflectionTestUtils.getField(command, "type")).isEqualTo(MaterialType.BOOK);
        assertThat(ReflectionTestUtils.getField(command, "onlyAvailable")).isEqualTo(true);
        assertThat(ReflectionTestUtils.getField(command, "limit")).isEqualTo(10);
    }

    @Test
    void rejectsAnInvalidIsbnWithAUsefulMessage() {
        var cl = new CommandLine(new LoanCreateCommand(mock(…), mock(…)));

        assertThatThrownBy(() -> cl.parseArgs("--isbn=1234", "--employee=1"))
            .isInstanceOf(CommandLine.ParameterException.class)
            .hasMessageContaining("is not a valid ISBN-13");
    }

    @Test
    void requiresTheMandatoryOptions() {
        var cl = new CommandLine(new LoanCreateCommand(mock(…), mock(…)));

        assertThatThrownBy(() -> cl.parseArgs("--employee=1"))
            .isInstanceOf(CommandLine.MissingParameterException.class)
            .hasMessageContaining("--isbn");
    }
}

Level 3: end to end, capturing the output. The full command is run and you check what it writes and what code it returns:

@SpringBootTest
class BiblioTechCliIT {

    @Autowired RootCommand root;
    @Autowired CommandLine.IFactory factory;

    @Test
    void listingTheCatalogueAsJsonProducesValidJson() throws Exception {
        var capturedOut = new StringWriter();
        var capturedErr = new StringWriter();

        int exitCode = new CommandLine(root, factory)
                .setOut(new PrintWriter(capturedOut))         // Picocli allows redirection
                .setErr(new PrintWriter(capturedErr))
                .execute("catalog", "list", "--format=json");

        assertThat(exitCode).isEqualTo(ExitCode.OK);

        // The result must be VALID JSON: no banners or messages polluting it
        JsonNode tree = new ObjectMapper().readTree(capturedOut.toString());
        assertThat(tree.isArray()).isTrue();
        assertThat(tree).hasSize(3);
        assertThat(tree.get(0).get("title").asText()).isEqualTo("Effective Java");
    }

    @Test
    void returnsCode5WhenTheMaterialDoesNotExist() {
        int exitCode = new CommandLine(root, factory)
                .setExecutionExceptionHandler(new CliErrorHandler())
                .execute("catalog", "card", "978-9999999999");

        assertThat(exitCode).isEqualTo(ExitCode.NOT_FOUND);
    }

    @Test
    void returnsCode2WhenAMandatoryOptionIsMissing() {
        int exitCode = new CommandLine(root, factory).execute("loan", "create", "--employee=1");
        assertThat(exitCode).isEqualTo(2);      // usage error, the one Picocli uses by default
    }
}

The test proving the JSON is valid is especially valuable: it instantly detects that somebody has put a System.out.println("Processing…") where they should not have.

Common Mistakes and Tips

1. Mixing result and diagnostics on System.out. This is the most frequent mistake and the one that breaks automation the most. A single progress println invalidates | jq. Rule: if it is not the result, it goes to System.err.

2. Always returning 0. A script cannot tell success from failure, and a nightly failure goes unnoticed for weeks. Return codes that mean something.

3. Printing stack traces at the user. The user can do nothing with a NullPointerException. The stack trace goes to the log; what goes on screen is a message with a cause, a suggestion and an incident identifier.

4. Not detecting whether there is a terminal. Colours, progress bars and decorative headers must switch themselves off when the output is redirected. Check System.console() != null.

5. Ignoring NO_COLOR. It is an established convention. If you do not honour it, yours will be the tool that ruins the output in somebody's terminal.

6. Progress bars that repaint without limits. Repainting 5,000 times a second burns more CPU than the real work and saturates the terminal. Cap it at one repaint every 100 ms.

7. Forgetting the encoding. Without -Dfile.encoding=UTF-8 or an explicit PrintStream, "Gödel, Escher, Bach" comes out as "G?del, Escher, Bach" when redirecting on some systems.

8. A --help that does not help. "Shows materials" explains nothing. Write useful descriptions and add examples in the footer: it is the first thing that gets read and the last thing that gets written.

9. Destructive operations with no confirmation or simulation. Every command that deletes or modifies in bulk should have --dry-run and, in interactive mode, ask for confirmation. In non-interactive mode, --yes to skip it.

10. Duplicating logic between the CLI and the web. If LoanCreateCommand validates business rules that the REST controller also validates, sooner or later they diverge. Both must limit themselves to calling the same use case.

A final tip: test your CLI inside a pipe from day one. bibliotech catalog list --format=json | jq . instantly detects almost every mistake on this list.

Exercises

Exercise 1: a complete loan return command

Implement bibliotech loan return with:

  • A required positional parameter: the loan's identifier.
  • An optional --date (today by default), validating that it is not in the future.
  • --dry-run: works out and shows the fine without recording the return.
  • Output in all three formats.
  • Codes: 0 success, 5 loan not found, 6 already returned, 2 invalid date.
  • A success message that states the fine if there is one.

Exercise 2: standard input and exit codes

Implement bibliotech notices send so that it:

  • By default, sends notices for the loans due in --days-ahead days (3 by default).
  • With --from-stdin, reads employee identifiers from standard input (one per line) and only notifies those.
  • Has --dry-run to show who would be notified without sending anything.
  • Returns 0 if all were sent, 3 if there was nothing to send, 4 if some failed and 7 if the mail server is not responding.
  • Shows progress only if there is a terminal.

Write as well the shell script that would use it in a cron job with retries.

Exercise 3: a reusable table formatter

Write a generic ConsoleTable class that:

  • Accepts columns with a name, an extractor function and an alignment.
  • Works out the widths automatically, with a maximum per column and truncation with "…".
  • Supports a header separator and optional totals at the foot.
  • Adapts to the terminal width where possible.
  • Can be used like this:
ConsoleTable.of(materials)
    .column("ISBN", m -> m.getIsbn().value())
    .column("TITLE", Material::getTitle, 45)
    .column("TYPE", m -> m.type().name())
    .numericColumn("FREE", m -> m.availableCopies())
    .withTotals()
    .print(output);

Solutions

Solution 1

@Command(name = "return",
    description = "Records the return of a loan.",
    footerHeading = "%nExamples:%n",
    footer = {
        "  bibliotech loan return 42",
        "  bibliotech loan return 42 --date=2026-03-15",
        "  bibliotech loan return 42 --dry-run --format=json"
    })
@Component
public class LoanReturnCommand implements Callable<Integer> {

    private final ManageLoans manager;
    private final FineCalculator calculator;
    private final Output output;
    private final Clock clock;

    @Spec CommandLine.Model.CommandSpec spec;

    public LoanReturnCommand(ManageLoans manager, FineCalculator calculator,
                             Output output, Clock clock) {
        this.manager = manager;
        this.calculator = calculator;
        this.output = output;
        this.clock = clock;
    }

    @Parameters(index = "0", paramLabel = "<loanId>",
                description = "Identifier of the loan to return.")
    private Long loanId;

    @Option(names = "--date", description = "Return date (yyyy-MM-dd). Today by default.")
    private LocalDate date;

    @Option(names = "--dry-run",
            description = "Works out the fine without recording the return.")
    private boolean dryRun;

    @Override
    public Integer call() {
        LocalDate today = LocalDate.now(clock);
        LocalDate effectiveDate = (date != null) ? date : today;

        // Cross-field validation: ParameterException produces message + usage + code 2
        if (effectiveDate.isAfter(today)) {
            throw new CommandLine.ParameterException(spec.commandLine(),
                "--date (%s) cannot be later than today (%s)."
                    .formatted(effectiveDate, today));
        }

        Loan loan = manager.find(loanId)
                .orElseThrow(() -> new LoanNotFoundException(loanId));

        if (loan.getReturnDate().isPresent()) {
            // CliErrorHandler translates the exception into code 6 (CONFLICT)
            throw new LoanAlreadyReturnedException(loanId,
                    loan.getReturnDate().orElseThrow());
        }

        if (effectiveDate.isBefore(loan.getLoanDate())) {
            throw new CommandLine.ParameterException(spec.commandLine(),
                "--date (%s) is earlier than the loan date (%s)."
                    .formatted(effectiveDate, loan.getLoanDate()));
        }

        if (dryRun) {
            Money fine = calculator.calculate(loan, effectiveDate);
            output.writeReturn(new ReturnResult(
                    loan.getId(), loan.materialTitle(), loan.employeeName(),
                    effectiveDate, fine, true));
            output.warn("DRY RUN: nothing has been recorded.");
            return ExitCode.OK;
        }

        ReturnResult result = manager.returnItem(loanId, effectiveDate);
        output.writeReturn(result);

        if (result.fine().isPositive()) {
            output.success("Return recorded. Fine: %s (%d days late)."
                    .formatted(result.fine(), result.daysLate()));
        } else {
            output.success("Return recorded on time. No fine.");
        }
        return ExitCode.OK;
    }
}

The formatting, in Output:

public void writeReturn(ReturnResult r) {
    switch (format) {
        case TABLE -> {
            out.printf("%-14s %s%n", "Loan:", "#" + r.loanId());
            out.printf("%-14s %s%n", "Material:", r.materialTitle());
            out.printf("%-14s %s%n", "Employee:", r.employeeName());
            out.printf("%-14s %s%n", "Returned:", r.date());
            out.printf("%-14s %s%n", "Fine:",
                    r.fine().isPositive() ? red(r.fine().toString()) : green("no fine"));
        }
        case CSV -> {
            if (showDecoration()) out.println("id;material;employee;date;fine");
            out.printf("%d;%s;%s;%s;%s%n", r.loanId(), escape(r.materialTitle()),
                    escape(r.employeeName()), r.date(), r.fine().amount());
        }
        case JSON -> json(r);
    }
}

Checking the exit codes:

$ bibliotech loan return 42;                    echo $?   # 0
$ bibliotech loan return 9999;                  echo $?   # 5 (not found)
$ bibliotech loan return 42;                    echo $?   # 6 (already returned)
$ bibliotech loan return 42 --date=2099-01-01;  echo $?   # 2 (wrong usage)

Solution 2

@Command(name = "send",
    description = "Sends notices of an approaching due date to the employees affected.",
    footerHeading = "%nExit codes:%n",
    footer = {
        "  0  every notice was sent",
        "  3  there was no notice to send",
        "  4  some notices failed",
        "  7  the mail server is not responding"
    })
@Component
public class NoticesSendCommand implements Callable<Integer> {

    private final NoticeService notices;
    private final LoanRepository loans;
    private final Output output;
    private final CancellationManager cancellation;
    private final Clock clock;

    @Option(names = "--days-ahead", defaultValue = "3",
            description = "Notify about due dates N days away. Default: ${DEFAULT-VALUE}")
    private int daysAhead;

    @Option(names = "--from-stdin",
            description = "Reads employee identifiers from standard input, one per line.")
    private boolean fromStdin;

    @Option(names = "--dry-run", description = "Shows who would be notified, without sending anything.")
    private boolean dryRun;

    @Override
    public Integer call() throws IOException {
        List<Loan> target = selectLoans();

        if (target.isEmpty()) {
            output.info("There is no loan that requires a notice.");
            return ExitCode.NO_RESULTS;                  // 3: NOT an error
        }

        output.info("Preparing %d notices%s…".formatted(target.size(), dryRun ? " (DRY RUN)" : ""));

        var progress = new ProgressBar(target.size(), output);   // switches itself off with no terminal
        int sent = 0;
        List<SendFailure> failures = new ArrayList<>();

        for (Loan l : target) {
            if (cancellation.cancelled()) {
                progress.finish();
                output.warn("Cancelled. Sent %d of %d.".formatted(sent, target.size()));
                return ExitCode.INTERRUPTED;
            }
            try {
                if (dryRun) {
                    // The dry run's output IS the result: it goes to stdout
                    output.result("%s <%s> — \"%s\" is due on %s"
                            .formatted(l.employeeName(), l.employeeEmail(),
                                       l.materialTitle(), l.getDueDate()));
                } else {
                    notices.send(Notice.forDueDate(l));
                }
                sent++;
            } catch (GatewayUnavailableException e) {
                // A mail server that is down affects EVERYONE: there is no point carrying on
                progress.finish();
                throw e;                                  // the handler translates it into 7
            } catch (BiblioTechException e) {
                failures.add(new SendFailure(l.getId(), l.employeeEmail(), e.getMessage()));
            } finally {
                progress.advance();
            }
        }
        progress.finish();

        if (!failures.isEmpty()) {
            output.warn("%d notices failed:".formatted(failures.size()));
            failures.forEach(f -> output.warn("  loan #%d (%s): %s"
                    .formatted(f.loanId(), f.recipient(), f.reason())));
            output.info("Sent %d of %d.".formatted(sent, target.size()));
            return ExitCode.PARTIAL;                      // 4
        }

        output.success("Sent %d notices.".formatted(sent));
        return ExitCode.OK;
    }

    private List<Loan> selectLoans() throws IOException {
        LocalDate cutoff = LocalDate.now(clock).plusDays(daysAhead);

        if (!fromStdin) {
            return loans.activeDueBefore(cutoff);
        }

        List<Long> ids = readIdsFromStdin();
        if (ids.isEmpty()) {
            output.warn("No identifier was read from standard input.");
            return List.of();
        }
        output.detail("Filtering by %d employees read from stdin".formatted(ids.size()));
        return loans.activeDueBefore(cutoff).stream()
                .filter(l -> ids.contains(l.getEmployeeId()))
                .toList();
    }

    private List<Long> readIdsFromStdin() throws IOException {
        try (var reader = new BufferedReader(new InputStreamReader(System.in, UTF_8))) {
            return reader.lines()
                    .map(String::strip)
                    .filter(l -> !l.isEmpty() && !l.startsWith("#"))
                    .map(l -> {
                        try {
                            return Long.parseLong(l);
                        } catch (NumberFormatException e) {
                            output.warn("Ignoring the non-numeric line: '%s'".formatted(l));
                            return null;
                        }
                    })
                    .filter(Objects::nonNull)
                    .distinct()
                    .toList();
        }
    }
}

Usage and composition:

# Every due date in the next 3 days
bibliotech notices send

# Only the employees in the Architecture department
bibliotech employee list --department=Architecture --format=csv \
  | cut -d';' -f1 | tail -n +2 \
  | bibliotech notices send --from-stdin --days-ahead=7

# See who would be notified, without sending anything
bibliotech notices send --dry-run --format=json | jq -r '.[].email'

A script for cron:

#!/usr/bin/env bash
# /opt/bibliotech/scripts/daily-notices.sh
# Run with: 0 8 * * * /opt/bibliotech/scripts/daily-notices.sh
set -uo pipefail                    # no -e: we want to handle the codes ourselves

LOG="/var/log/bibliotech/notices-$(date +%F).log"
MAX_ATTEMPTS=3

for attempt in $(seq 1 $MAX_ATTEMPTS); do
  # The result to the log; the diagnostics too, but separable just in case
  bibliotech notices send --days-ahead=3 --quiet >>"$LOG" 2>&1
  code=$?

  case $code in
    0) echo "$(date -Is) OK: notices sent" >>"$LOG"; exit 0 ;;
    3) echo "$(date -Is) Nothing to send today" >>"$LOG"; exit 0 ;;   # NOT a failure
    4) echo "$(date -Is) WARNING: partial send; check the log" >>"$LOG"
       mail -s "BiblioTech: partial notices" [email protected] <"$LOG"
       exit 0 ;;
    7) echo "$(date -Is) Mail unavailable; retry $attempt of $MAX_ATTEMPTS" >>"$LOG"
       sleep $((attempt * 120)) ;;                                    # 2, 4, 6 minutes
    *) echo "$(date -Is) Unrecoverable ERROR (code $code)" >>"$LOG"
       mail -s "BiblioTech: notice job failed" [email protected] <"$LOG"
       exit "$code" ;;
  esac
done

echo "$(date -Is) ERROR: retries exhausted" >>"$LOG"
mail -s "BiblioTech: mail down after 3 attempts" [email protected] <"$LOG"
exit 7

Notice the design decision that makes all of this useful: code 3 does not raise an alarm (having no due dates on a Tuesday is normal) and 7 does trigger a retry. With a single generic error code, this script could not be written.

Solution 3

/**
 * A console table formatter, with automatic widths.
 * Usage:
 *   ConsoleTable.of(materials)
 *       .column("ISBN", m -> m.getIsbn().value())
 *       .column("TITLE", Material::getTitle, 45)
 *       .numericColumn("FREE", Material::availableCopies)
 *       .withTotals()
 *       .print(output);
 */
public class ConsoleTable<T> {

    private static final int DEFAULT_TERMINAL_WIDTH = 120;
    private static final String SEPARATOR = "  ";

    private final List<T> rows;
    private final List<Column<T>> columns = new ArrayList<>();
    private boolean totals = false;

    private ConsoleTable(List<T> rows) { this.rows = rows; }

    public static <T> ConsoleTable<T> of(List<T> rows) { return new ConsoleTable<>(rows); }

    // --- Column definition ---

    public ConsoleTable<T> column(String title, Function<T, String> extractor) {
        return column(title, extractor, Integer.MAX_VALUE);
    }

    public ConsoleTable<T> column(String title, Function<T, String> extractor, int maxWidth) {
        columns.add(new Column<>(title, extractor, Alignment.LEFT, maxWidth, null));
        return this;
    }

    public ConsoleTable<T> numericColumn(String title, ToLongFunction<T> extractor) {
        columns.add(new Column<>(title,
                t -> String.valueOf(extractor.applyAsLong(t)),
                Alignment.RIGHT, 15, extractor));
        return this;
    }

    public ConsoleTable<T> withTotals() { this.totals = true; return this; }

    // --- Printing ---

    public void print(Output output) {
        if (columns.isEmpty()) throw new IllegalStateException("Define at least one column");

        int[] widths = calculateWidths();
        fitToTerminal(widths);

        PrintStream out = output.out();

        if (output.showDecoration()) {
            out.println(row(widths, i -> columns.get(i).title()));
            out.println("-".repeat(totalWidth(widths)));
        }

        for (T item : rows) {
            out.println(row(widths, i -> value(columns.get(i), item, widths[i])));
        }

        if (totals && output.showDecoration()) {
            out.println("-".repeat(totalWidth(widths)));
            out.println(row(widths, this::columnTotal));
            out.printf("%d rows%n", rows.size());
        }
    }

    // --- Working out the widths ---

    private int[] calculateWidths() {
        int[] widths = new int[columns.size()];
        for (int i = 0; i < columns.size(); i++) {
            Column<T> c = columns.get(i);
            int contentWidth = rows.stream()
                    .map(c.extractor())
                    .mapToInt(String::length)
                    .max().orElse(0);
            // The width is the greater of title and content, capped by the maximum
            widths[i] = Math.min(c.maxWidth(), Math.max(c.title().length(), contentWidth));
        }
        return widths;
    }

    /**
     * If the table does not fit, proportionally shrinks the widest text
     * columns, keeping a minimum of 8 characters.
     */
    private void fitToTerminal(int[] widths) {
        int available = terminalWidth();
        int total = totalWidth(widths);
        if (total <= available) return;

        int excess = total - available;
        // Trim from largest to smallest until the excess is absorbed
        List<Integer> candidates = IntStream.range(0, widths.length)
                .boxed()
                .filter(i -> columns.get(i).alignment() == Alignment.LEFT)
                .sorted(Comparator.comparingInt((Integer i) -> widths[i]).reversed())
                .toList();

        for (int i : candidates) {
            if (excess <= 0) break;
            int trimmable = Math.max(0, widths[i] - 8);
            int trim = Math.min(trimmable, excess);
            widths[i] -= trim;
            excess -= trim;
        }
    }

    private int terminalWidth() {
        // COLUMNS is exported by the shell; if it is not there, use the default value
        String columns = System.getenv("COLUMNS");
        try {
            return columns != null ? Integer.parseInt(columns) : DEFAULT_TERMINAL_WIDTH;
        } catch (NumberFormatException e) {
            return DEFAULT_TERMINAL_WIDTH;
        }
    }

    private int totalWidth(int[] widths) {
        return Arrays.stream(widths).sum() + SEPARATOR.length() * (widths.length - 1);
    }

    // --- Cell formatting ---

    private String row(int[] widths, IntFunction<String> cell) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < widths.length; i++) {
            if (i > 0) sb.append(SEPARATOR);
            String text = truncate(cell.apply(i), widths[i]);
            sb.append(columns.get(i).alignment() == Alignment.RIGHT
                    ? " ".repeat(widths[i] - text.length()) + text
                    : text + " ".repeat(widths[i] - text.length()));
        }
        return sb.toString().stripTrailing();     // no trailing spaces: they are messy when copied
    }

    private String value(Column<T> c, T item, int width) {
        return truncate(c.extractor().apply(item), width);
    }

    private static String truncate(String text, int max) {
        if (text == null) return "";
        return text.length() <= max ? text : text.substring(0, Math.max(0, max - 1)) + "…";
    }

    private String columnTotal(int i) {
        Column<T> c = columns.get(i);
        if (c.adder() == null) return i == 0 ? "TOTAL" : "";
        long sum = rows.stream().mapToLong(c.adder()).sum();
        return String.valueOf(sum);
    }

    // --- Helper types ---

    private enum Alignment { LEFT, RIGHT }

    private record Column<T>(String title,
                             Function<T, String> extractor,
                             Alignment alignment,
                             int maxWidth,
                             ToLongFunction<T> adder) { }
}

Usage and output:

ConsoleTable.of(materials)
    .column("ISBN", m -> m.getIsbn().value())
    .column("TITLE", Material::getTitle, 45)
    .column("TYPE", m -> m.type().name())
    .numericColumn("FREE", Material::availableCopies)
    .withTotals()
    .print(output);
ISBN            TITLE            TYPE  FREE
-------------------------------------------
978-0000000001  Effective Java   BOOK     2
978-0000000002  Design Patterns  BOOK     0
978-0000000003  Refactoring      BOOK     1
-------------------------------------------
TOTAL                                     3
3 rows

The design's advantages: it is generic (it works for materials, loans, employees or anything else), it uses the Builder from 12-02, it honours showDecoration() so that in a pipe only the data comes out, and it adapts to the terminal width without falling out of alignment.

Conclusion

BiblioTech can now be used.

You are clear about when a CLI is the right interface —repeatable, automatable tasks, or ones for technical people— and about the four properties that make it good: predictable, composable, with useful errors and honest about what it does. And you know why the Scanner menu from module 2 was not this: that one was used by a person; this one is used by a person and by a script.

You saw the real limits of parsing by hand —the --option=value form, grouped options, type conversion, the ++i that blows up on the index— and that is why you adopted Picocli: annotations for commands, options and positionals; automatic conversion to more than forty types including those from java.time; your own converters that make the field an Isbn and not a String, with the error appearing during parsing rather than halfway through execution; exclusive groups; generated help with examples in the footer; and autocompletion for bash and zsh for one line of configuration.

You integrated it with Spring Boot the right way: the commands are beans that receive the use cases through the constructor, with no Service Locator, with web-application-type: none and banner-mode: off —because an ASCII banner breaks a JSON pipe—. And you designed the tool's complete surface, with subcommands by area, options inherited with ScopeType.INHERIT, and commands that print nothing on their own: they delegate to Output.

You internalised the rule that separates a professional CLI from a program that writes things out: the result goes to standard output; everything else goes to standard error. That is where working pipes come from, and uncontaminated redirections, and tests that verify that the JSON is valid JSON. And next to it you put exit codes that mean something, with the distinction that really matters —CONFLICT is not retried, UNAVAILABLE is—, which is what makes it possible to write exercise 2's cron script with retries.

You format the output as a table with calculated widths, as CSV with the escaping that 07-07 taught you not to improvise, and as JSON with module 11's Jackson, with three verbosity levels and decoration that disappears by itself when the output is not going to a terminal. You handle long operations with a progress bar that paints itself on standard error, caps itself at one repaint every 100 ms and switches itself off with no terminal; and with Java 21's virtual threads for the concurrent import that began in module 8. You cancel cleanly with Ctrl+C through a shutdown hook that picks up module 7's orderly shutdown, returning the POSIX convention's 130. You use colours while honouring NO_COLOR, TERM=dumb and the absence of a terminal, without colour ever being the only carrier of information.

Your errors have four parts —what, why, what to do and an incident identifier— translated from module 6's BiblioTechException hierarchy with Java 21's pattern matching, leaving the full stack trace in the log correlated with MDC from 11-07 and never on the user's screen. You package it as an executable jar with its launch script and you know the options for instant start-up: CDS, jpackage and GraalVM Native Image. And you test the CLI at three levels: the command's logic with Mockito, the parsing with parseArgs, and end to end capturing the output with setOut/setErr and checking the returned code.

One obvious limitation remains, and it is not a technical one: Marta Ruiz is not going to open a terminal to check whether "Refactoring" is available. The CLI solves automation and technical people; it does not solve access for the rest of the company, nor integration with other applications, nor a future mobile app.

The next lesson builds the second inbound adapter: the bibliotech-web module, a complete REST API with Spring Boot. You will see the request-response cycle, the embedded server and the DispatcherServlet —and you will discover that all of that is exactly what you solved by hand with sockets in module 9—, REST design with its verbs and its status codes, validation, global error handling with Problem Details, pagination, automatic documentation with OpenAPI and testing the web layer. With the same use cases as always underneath.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved