BiblioTech tiene arquitectura, módulos, patrones y cuarenta y una pruebas. Y sigue sin que nadie pueda usarlo.

Esta lección construye la primera interfaz real del proyecto: el módulo bibliotech-consola. Y conviene desactivar de entrada un prejuicio muy extendido: la consola no es «la interfaz de segunda, mientras llega la web». En una empresa como Nexus Software, la CLI es la interfaz que más se usa sin que nadie la vea, porque es la única que se puede meter en un cron, encadenar con otras herramientas, ejecutar por SSH en un servidor sin entorno gráfico y llamar desde un script de despliegue.

En el módulo 2 hiciste un menú interactivo con Scanner y un switch. Aquello era un ejercicio de flujo de control. Lo que vamos a construir aquí es otra cosa: una herramienta con subcomandos, opciones tipadas y validadas, ayuda generada automáticamente, autocompletado en el terminal, códigos de salida con significado, salida en varios formatos, tuberías, colores que se desactivan solos cuando no procede, barras de progreso, cancelación limpia con Ctrl+C y un jar ejecutable con su script de arranque.

La diferencia entre las dos cosas se resume en una frase: el menú del módulo 2 lo usa una persona; esta CLI la usa una persona y también un script. Y diseñar para las dos a la vez es lo que la hace profesional.

Al terminar sabrás cuándo una CLI es la interfaz correcta, dominarás Picocli integrado con Spring Boot, diseñarás una jerarquía de subcomandos coherente, entenderás por qué la separación entre salida estándar y salida de error importa de verdad, usarás códigos de salida que los scripts puedan interpretar, formatearás la salida para humanos y para máquinas, gestionarás operaciones largas con progreso y cancelación, empaquetarás la aplicación y —algo que casi nadie hace— probarás una aplicación de consola.

Contenido

  1. Cuándo una CLI es la interfaz correcta
  2. Qué hace buena a una CLI
  3. Argumentos de línea de comandos a mano, y sus límites
  4. Picocli: el modelo de anotaciones
  5. Opciones, parámetros y tipos
  6. Validación y conversión de tipos
  7. Subcomandos y jerarquía
  8. Ayuda automática y autocompletado
  9. Integración con Spring Boot
  10. Diseño de la CLI de BiblioTech
  11. Modo de un solo comando frente a modo interactivo
  12. Entrada y salida estándar: componer con tuberías
  13. Códigos de salida
  14. Formatear la salida: tabla, CSV y JSON
  15. Verbosidad: --silencioso y --verboso
  16. Operaciones largas: progreso y señales de vida
  17. Cancelación limpia con Ctrl+C
  18. Colores ANSI y cuándo desactivarlos
  19. Errores orientados al usuario
  20. Empaquetado y distribución
  21. Probar una aplicación de consola
  22. Errores Comunes y Consejos
  23. Ejercicios
  24. Conclusión

  1. Cuándo una CLI es la interfaz correcta

Situación ¿CLI? Por qué
Tarea programada nocturna (avisos de vencimiento) cron no sabe pulsar botones
Importación masiva del catálogo Se ejecuta por SSH, sin entorno gráfico, y se puede redirigir la salida a un fichero
Herramienta interna para el equipo técnico Rápida de escribir, rápida de usar, componible
Paso de un pipeline de despliegue Códigos de salida que el pipeline interpreta
Diagnóstico en producción a las 3 de la mañana Es lo único que hay en un servidor
Consulta del catálogo por cualquier empleado No Necesitan buscar visualmente; web
Alta de un material con quince campos No Formulario con validación en vivo
Panel de indicadores No Gráficos

La regla útil: una CLI gana cuando la tarea es repetible, automatizable o la ejecuta alguien técnico. Pierde cuando la tarea es exploratoria o la ejecuta alguien que no vive en un terminal.

Y ambas conviven perfectamente. BiblioTech tendrá CLI (esta lección) y API REST (12-04), compartiendo exactamente los mismos casos de uso del módulo bibliotech-aplicacion. Esa es la recompensa de la arquitectura de 12-01: dos adaptadores de entrada, un solo núcleo.

  1. Qué hace buena a una CLI

Cuatro propiedades, y ninguna es opcional:

Predecible. Las mismas convenciones que el resto de herramientas del sistema: -v y --verboso, --help, --version, verbo antes que sustantivo o al revés pero siempre igual. Una CLI que inventa su propia sintaxis obliga a leer la documentación cada vez.

Componible. Lee de la entrada estándar, escribe en la salida estándar, y los diagnósticos van a la salida de error. Eso permite:

bibliotech catalogo listar --formato=csv | grep "Java" | wc -l
cat isbns.txt | bibliotech catalogo importar --desde-stdin
bibliotech informe multas --formato=json | jq '.[] | select(.importe > 10)'

Con buenos mensajes de error. Un error debe decir qué pasó, por qué y qué hacer. Compara:

Error: NullPointerException

con:

Error: no se encontró ningún material con ISBN 978-0000000009.

  Causa: el ISBN no existe en el catálogo.
  Sugerencia: comprueba el ISBN con 'bibliotech catalogo buscar --titulo="..."'
              o impórtalo con 'bibliotech catalogo importar'.

Honesta con el estado. Si va a tardar, lo dice. Si va a modificar datos, lo advierte. Si tiene un modo de simulación (--simular), lo ofrece para las operaciones destructivas.

  1. Argumentos de línea de comandos a mano, y sus límites

Java te entrega los argumentos en String[] args. Parsearlos a mano parece trivial:

public static void main(String[] args) {
    String formato = "tabla";
    boolean verboso = false;
    String isbn = null;

    for (int i = 0; i < args.length; i++) {
        switch (args[i]) {
            case "--formato" -> formato = args[++i];
            case "-v", "--verboso" -> verboso = true;
            case "--isbn" -> isbn = args[++i];
            default -> {
                System.err.println("Opción desconocida: " + args[i]);
                System.exit(2);
            }
        }
    }
    // …
}

Funciona para tres opciones. Ahora la lista de lo que no hace, y que un usuario de terminal espera que funcione:

Falta Ejemplo que falla
Forma --opcion=valor --formato=json se lee como una opción desconocida
Opciones cortas agrupadas -vq en lugar de -v -q
-- para separar opciones de argumentos bibliotech buscar -- --titulo-raro
Conversión de tipos Todo es String; convertir y validar a mano
Opciones obligatorias Nada comprueba que --isbn esté presente
Grupos exclusivos --formato=json --formato=csv no da error
Ayuda Hay que escribirla y mantenerla sincronizada a mano
Índice fuera de rango --formato al final: ArrayIndexOutOfBoundsException
Subcomandos catalogo listar requiere otro nivel de parseo
Autocompletado Imposible

Ese último ++i sin comprobar el límite es un bug real esperando a que alguien escriba bibliotech listar --formato y pulse Enter.

Conclusión: parsear a mano está bien para un script de veinte líneas. Para una herramienta de verdad, se usa una librería. En Java hay tres candidatas serias:

Librería Ventajas Inconvenientes
Picocli Anotaciones, subcomandos, colores, autocompletado, sin dependencias, soporte GraalVM
Apache Commons CLI Muy estable, veterana API imperativa y verbosa; sin subcomandos nativos
JCommander Sencilla, con anotaciones Menos activa; menos funcionalidades

Usaremos Picocli: es la opción estándar de facto en Java moderno, y es la que integra Spring Boot con un starter oficial.

  1. Picocli: el modelo de anotaciones

Dependencia en bibliotech-consola/pom.xml:

<dependencies>
  <dependency>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech-aplicacion</artifactId>
  </dependency>
  <dependency>
    <groupId>com.nexussoftware</groupId>
    <artifactId>bibliotech-infraestructura</artifactId>
  </dependency>

  <dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli-spring-boot-starter</artifactId>   <!-- trae picocli + integración -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>           <!-- sin 'web': esto no es un servidor -->
  </dependency>
</dependencies>

El «hola mundo» de Picocli, con todo lo esencial:

package com.nexussoftware.bibliotech.consola;

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

@Command(
    name = "bibliotech",                          // cómo se invoca
    mixinStandardHelpOptions = true,              // añade --help y --version gratis
    version = "BiblioTech CLI 1.0.0",
    description = "Herramienta de gestión de la biblioteca técnica de Nexus Software.")
public class Ejemplo implements Callable<Integer> {   // Callable<Integer>: el int es el código de salida

    @Parameters(index = "0", description = "ISBN del material a consultar.")
    private String isbn;

    @Option(names = {"-f", "--formato"}, defaultValue = "tabla",
            description = "Formato de salida: ${COMPLETION-CANDIDATES}. Por defecto: ${DEFAULT-VALUE}")
    private Formato formato;

    @Override
    public Integer call() {
        System.out.println("Consultando " + isbn + " en formato " + formato);
        return 0;                                  // 0 = éxito
    }

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

Con esas veinte líneas ya tienes:

$ java -jar ejemplo.jar --help
Usage: bibliotech [-hV] [-f=<formato>] <isbn>
Herramienta de gestión de la biblioteca técnica de Nexus Software.
      <isbn>          ISBN del material a consultar.
  -f, --formato=<formato>
                      Formato de salida: TABLA, CSV, JSON. Por defecto: TABLA
  -h, --help          Show this help message and exit.
  -V, --version       Print version information and exit.

Los tres elementos del modelo:

Anotación Qué marca Ejemplo en la línea de comandos
@Command La clase (o método) que es un comando bibliotech
@Option Un argumento con nombre --formato=json, -v
@Parameters Un argumento posicional el 978-0000000001 de bibliotech ficha 978-0000000001

  1. Opciones, parámetros y tipos

Picocli convierte a más de 40 tipos automáticamente, incluidos los de java.time (módulo 10):

@Command(name = "prestamo")
class OpcionesEjemplo {

    // Booleana: su sola presencia la pone a true
    @Option(names = {"-v", "--verboso"}, description = "Muestra detalle de la ejecución.")
    boolean verboso;

    // Obligatoria: si falta, Picocli produce un error claro
    @Option(names = "--isbn", required = true, description = "ISBN del material.")
    String isbn;

    // Con valor por defecto
    @Option(names = "--dias", defaultValue = "15", description = "Duración en días.")
    int dias;

    // Tipo de java.time: conversión automática desde ISO-8601
    @Option(names = "--desde", description = "Fecha de inicio (yyyy-MM-dd).")
    LocalDate desde;

    // Enum: Picocli valida los valores permitidos y los lista en la ayuda
    @Option(names = "--formato", defaultValue = "TABLA")
    Formato formato;

    // Repetible: --etiqueta java --etiqueta diseño
    @Option(names = "--etiqueta", description = "Etiqueta (repetible).")
    List<String> etiquetas = new ArrayList<>();

    // Con aridad: exactamente dos valores
    @Option(names = "--rango", arity = "2", paramLabel = "<desde> <hasta>")
    int[] rango;

    // Mapa: --propiedad clave=valor
    @Option(names = "-D", description = "Propiedad adicional.")
    Map<String, String> propiedades = new LinkedHashMap<>();

    // Sensible: Picocli la pide por teclado sin mostrarla si no se pasa
    @Option(names = "--clave", interactive = true, arity = "0..1")
    char[] clave;

    // Posicionales: todos los restantes
    @Parameters(index = "0", description = "Fichero de entrada.")
    Path fichero;

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

Sobre interactive = true y char[]: es la forma correcta de pedir una contraseña. char[] en lugar de String para poder sobrescribirla en memoria tras usarla; una String queda en el pool hasta que el recolector la retire, y puede aparecer en un volcado de memoria (se retoma en 12-07).

Grupos exclusivos, para opciones incompatibles entre sí:

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

@ArgGroup(exclusive = true, multiplicity = "1")   // exactamente uno de los tres
OrigenDatos origen;

Si el usuario pasa dos, Picocli lo rechaza con un mensaje claro, sin que escribas ninguna comprobación.

  1. Validación y conversión de tipos

Picocli valida tipos; la validación de negocio la escribes tú, y hay un sitio correcto para ella: un conversor propio, para que el error aparezca en el parseo y no a mitad de la ejecución.

/** Convierte el texto de la línea de comandos en el objeto de valor del dominio. */
public class ConversorIsbn implements CommandLine.ITypeConverter<Isbn> {

    @Override
    public Isbn convert(String valor) {
        try {
            return Isbn.de(valor);          // el objeto de valor ya valida (12-02)
        } catch (IsbnInvalidoException e) {
            // TypeConversionException produce un mensaje de uso, no una traza
            throw new CommandLine.TypeConversionException(
                "'" + valor + "' no es un ISBN-13 válido. Formato esperado: 978-XXXXXXXXXX");
        }
    }
}
@Option(names = "--isbn", required = true, converter = ConversorIsbn.class)
private Isbn isbn;      // ¡el campo es del tipo del dominio, no String!

Resultado en el terminal:

$ bibliotech prestamo crear --isbn=1234 --empleado=1
Invalid value for option '--isbn': '1234' no es un ISBN-13 válido.
Formato esperado: 978-XXXXXXXXXX
Usage: bibliotech prestamo crear [-hV] --empleado=<id> --isbn=<isbn> [--dias=<n>]
…

Para reglas que dependen de varias opciones, se usa la especificación de comando:

@Spec CommandLine.Model.CommandSpec spec;

private void validar() {
    if (desde != null && hasta != null && desde.isAfter(hasta)) {
        throw new CommandLine.ParameterException(spec.commandLine(),
            "--desde (" + desde + ") no puede ser posterior a --hasta (" + hasta + ")");
    }
}

ParameterException es importante: hace que Picocli imprima el mensaje y el uso, y devuelva el código de salida de error de uso (2), que es lo que un script espera para «me has llamado mal».

  1. Subcomandos y jerarquía

Una CLI con más de cinco operaciones necesita subcomandos. El patrón que ha ganado en la industria es sustantivo + verbo (git remote add, docker container run, kubectl get pods), porque agrupa por área y escala mejor.

flowchart TD
    R["bibliotech"]
    C["catalogo"]
    P["prestamo"]
    A["avisos"]
    I["informe"]

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

    C --> C1["listar"]
    C --> C2["buscar"]
    C --> C3["importar"]
    C --> C4["ficha"]
    P --> P1["crear"]
    P --> P2["devolver"]
    P --> P3["renovar"]
    P --> P4["listar"]
    A --> A1["enviar"]
    A --> A2["previsualizar"]
    I --> I1["multas"]
    I --> I2["uso"]

El comando raíz declara sus hijos:

@Command(
    name = "bibliotech",
    mixinStandardHelpOptions = true,
    version = "BiblioTech CLI 1.0.0",
    description = "Gestión de la biblioteca técnica de Nexus Software.",
    subcommands = {
        ComandoCatalogo.class,
        ComandoPrestamo.class,
        ComandoAvisos.class,
        ComandoInforme.class,
        CommandLine.HelpCommand.class          // 'bibliotech help catalogo'
    })
@Component
public class ComandoRaiz implements Callable<Integer> {

    @Override
    public Integer call() {
        // Sin subcomando, mostrar la ayuda y devolver error de uso:
        // así un script sabe que la invocación estaba incompleta.
        CommandLine.usage(this, System.out);
        return CodigoSalida.ERROR_USO;
    }
}

Y un comando intermedio, que solo agrupa:

@Command(name = "catalogo",
         description = "Operaciones sobre el catálogo de materiales.",
         subcommands = {ComandoCatalogoListar.class, ComandoCatalogoBuscar.class,
                        ComandoCatalogoImportar.class, ComandoCatalogoFicha.class})
@Component
public class ComandoCatalogo implements Callable<Integer> {
    @Override public Integer call() {
        CommandLine.usage(this, System.out);
        return CodigoSalida.ERROR_USO;
    }
}

Opciones heredadas. Las globales (--formato, --verboso) se declaran una vez con scope = ScopeType.INHERIT:

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

    @Option(names = {"-f", "--formato"}, scope = CommandLine.ScopeType.INHERIT,
            defaultValue = "TABLA", description = "Formato de salida: ${COMPLETION-CANDIDATES}")
    Formato formato;

    @Option(names = {"-q", "--silencioso"}, scope = CommandLine.ScopeType.INHERIT,
            description = "Solo errores.")
    boolean silencioso;

    @Option(names = {"-v", "--verboso"}, scope = CommandLine.ScopeType.INHERIT,
            description = "Detalle de la ejecución.")
    boolean verboso;
}

Ahora bibliotech catalogo listar --formato=json funciona sin declarar --formato en cada subcomando.

  1. Ayuda automática y autocompletado

mixinStandardHelpOptions = true genera --help y --version. La calidad de esa ayuda depende de lo que escribas en description, y hay variables útiles:

Variable Se sustituye por
${DEFAULT-VALUE} El valor por defecto de la opción
${COMPLETION-CANDIDATES} Los valores válidos (útil con enum)
${sys:usuario} Una propiedad del sistema

Y se pueden añadir secciones completas, que es lo que separa una ayuda útil de una lista de opciones:

@Command(name = "importar",
    description = "Importa materiales al catálogo desde un fichero o desde la entrada estándar.",
    footerHeading = "%nEjemplos:%n",
    footer = {
        "  bibliotech catalogo importar catalogo.csv",
        "  bibliotech catalogo importar --formato-entrada=json datos.json --simular",
        "  cat isbns.txt | bibliotech catalogo importar --desde-stdin --enriquecer",
        "",
        "Códigos de salida: 0 correcto, 1 error, 2 uso incorrecto, 4 importación parcial."
    })
class ComandoCatalogoImportar implements Callable<Integer> { … }

Autocompletado. Picocli genera un script de completado para bash y zsh:

# Generar el script (una vez, en la construcción)
java -cp bibliotech-consola.jar picocli.AutoComplete \
     -n bibliotech com.nexussoftware.bibliotech.consola.ComandoRaiz

# Activarlo en la sesión
source bibliotech_completion

# Ahora funciona el tabulador
$ bibliotech cat<TAB>          → bibliotech catalogo
$ bibliotech catalogo <TAB>    → buscar  ficha  importar  listar
$ bibliotech catalogo listar --formato=<TAB>  → TABLA  CSV  JSON

Este detalle es de los que más agradecen quienes usan la herramienta a diario, y cuesta una línea en el POM.

  1. Integración con Spring Boot

El problema a resolver: los comandos necesitan los servicios de aplicación (GestionarPrestamos, ConsultarCatalogo), que son beans de Spring. Pero Picocli instancia los comandos por reflexión. Sin integración, acabarías con new ComandoPrestamo(contexto.getBean(...)), que es Service Locator, un antipatrón (12-02).

picocli-spring-boot-starter lo resuelve con una fábrica que delega en el contexto:

package com.nexussoftware.bibliotech.consola;

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

    private final ComandoRaiz comandoRaiz;
    private final CommandLine.IFactory fabrica;    // la aporta el starter de Picocli
    private int codigoSalida;

    public BiblioTechCli(ComandoRaiz comandoRaiz, CommandLine.IFactory fabrica) {
        this.comandoRaiz = comandoRaiz;
        this.fabrica = fabrica;
    }

    @Override
    public void run(String... args) {
        this.codigoSalida = new CommandLine(comandoRaiz, fabrica)
                .setCaseInsensitiveEnumValuesAllowed(true)     // --formato=json y JSON
                .setExecutionExceptionHandler(new ManejadorErroresCli())
                .execute(args);
    }

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

    public static void main(String[] args) {
        // SpringApplication.exit devuelve el código del ExitCodeGenerator,
        // y cierra el contexto ordenadamente antes de salir.
        System.exit(SpringApplication.exit(SpringApplication.run(BiblioTechCli.class, args)));
    }
}

Con esto, los comandos son beans normales y reciben sus dependencias por constructor:

@Command(name = "crear", description = "Crea un préstamo de un material a un empleado.")
@Component
public class ComandoPrestamoCrear implements Callable<Integer> {

    private final GestionarPrestamos gestor;      // ¡inyección por constructor, como siempre!
    private final Salida salida;

    public ComandoPrestamoCrear(GestionarPrestamos gestor, Salida salida) {
        this.gestor = gestor;
        this.salida = salida;
    }

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

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

    @Option(names = "--dias", description = "Duración. Por defecto, la del tipo de material.")
    private Integer dias;

    @Override
    public Integer call() {
        Prestamo prestamo = gestor.prestar(isbn, idEmpleado, dias);
        salida.exito("Préstamo #%d creado. Vence el %s."
                .formatted(prestamo.getId(), prestamo.getFechaVencimiento()));
        return CodigoSalida.OK;
    }
}

Un detalle de configuración importante. La CLI no debe arrancar un servidor web ni imprimir el banner de Spring. En bibliotech-consola/src/main/resources/application.yml:

spring:
  main:
    web-application-type: none      # sin Tomcat
    banner-mode: off                # sin banner ASCII contaminando la salida
  output:
    ansi:
      enabled: detect               # colores solo si el terminal los soporta

logging:
  pattern:
    console: "%d{HH:mm:ss} %-5level %msg%n"
  level:
    root: WARN                      # una CLI silenciosa por defecto
    com.nexussoftware.bibliotech: INFO

Que banner-mode esté a off no es cosmético: si la salida se va a encadenar con jq, el banner rompe el JSON.

  1. Diseño de la CLI de BiblioTech

La superficie completa de la herramienta:

Comando Descripción Opciones principales
catalogo listar Lista materiales --tipo, --disponibles, --limite, --ordenar-por
catalogo buscar Busca por criterios --titulo, --autor, --desde, --hasta
catalogo ficha Detalle de un material <isbn> posicional, --con-historial
catalogo importar Importa desde fichero o stdin --desde-stdin, --formato-entrada, --simular, --enriquecer, --hilos
prestamo crear Crea un préstamo --isbn, --empleado, --dias
prestamo devolver Registra una devolución <idPrestamo>, --fecha
prestamo renovar Renueva un préstamo <idPrestamo>, --dias
prestamo listar Lista préstamos --empleado, --estado, --vencidos
avisos enviar Envía avisos de vencimiento --dias-antelacion, --simular
informe multas Informe de multas --desde, --hasta, --empleado
informe uso Informe de uso mensual --periodo

Opciones globales, disponibles en todos:

Opción Efecto
-f, --formato=<TABLA|CSV|JSON> Formato de salida
-q, --silencioso Solo errores; sin cabeceras ni mensajes de progreso
-v, --verboso Detalle de la ejecución (repetible: -vv para trazas)
--sin-color Desactiva los colores ANSI
-h, --help / -V, --version Ayuda y versión

Un comando completo, con todo lo que hemos visto junto:

@Command(name = "listar",
    description = "Lista los materiales del catálogo.",
    footerHeading = "%nEjemplos:%n",
    footer = {
        "  bibliotech catalogo listar --tipo=LIBRO --disponibles",
        "  bibliotech catalogo listar --formato=csv > catalogo.csv",
        "  bibliotech catalogo listar --formato=json | jq '.[].titulo'"
    })
@Component
public class ComandoCatalogoListar implements Callable<Integer> {

    private final ConsultarCatalogo catalogo;
    private final Salida salida;

    public ComandoCatalogoListar(ConsultarCatalogo catalogo, Salida salida) {
        this.catalogo = catalogo;
        this.salida = salida;
    }

    @Option(names = "--tipo", description = "Filtrar por tipo: ${COMPLETION-CANDIDATES}")
    private TipoMaterial tipo;

    @Option(names = "--disponibles", description = "Solo materiales con unidades libres.")
    private boolean soloDisponibles;

    @Option(names = "--limite", defaultValue = "50",
            description = "Número máximo de resultados. Por defecto: ${DEFAULT-VALUE}")
    private int limite;

    @Option(names = "--ordenar-por", defaultValue = "TITULO",
            description = "Criterio de orden: ${COMPLETION-CANDIDATES}")
    private CriterioOrden orden;

    @Override
    public Integer call() {
        var criterio = CriterioBusqueda.builder()          // Builder de 12-02
                .tipo(tipo)
                .soloDisponibles(soloDisponibles)
                .construir();

        List<Material> resultados = catalogo.buscar(criterio, orden, limite);

        if (resultados.isEmpty()) {
            salida.aviso("No se encontró ningún material con esos criterios.");
            return CodigoSalida.SIN_RESULTADOS;            // 3: distinto de error
        }

        salida.escribirMateriales(resultados);             // el formato lo decide Salida
        salida.info("%d materiales.".formatted(resultados.size()));
        return CodigoSalida.OK;
    }
}

Observa que el comando no imprime nada directamente. Delega en Salida, que es quien sabe de formatos, colores, verbosidad y a qué flujo va cada cosa. Es responsabilidad única aplicada a la consola.

  1. Modo de un solo comando frente a modo interactivo

Aspecto Un solo comando Interactivo (REPL)
Invocación bibliotech prestamo crear --isbn=… bibliotech shell, y luego órdenes
Automatizable No
Componible con tuberías No
Coste por operación Arranque de la JVM cada vez (~1 s) Solo el primero
Adecuado para Scripts, cron, CI Exploración, muchas operaciones seguidas

Pueden convivir, y la clave para que convivan bien es que el modo interactivo no duplique lógica: se limita a leer una línea, trocearla y pasársela al mismo CommandLine.

@Command(name = "shell", description = "Modo interactivo. Escribe 'salir' para terminar.")
@Component
public class ComandoShell implements Callable<Integer> {

    private final ComandoRaiz raiz;
    private final CommandLine.IFactory fabrica;

    @Override
    public Integer call() {
        // Console es null si la entrada está redirigida: entonces el shell no tiene sentido
        Console consola = System.console();
        if (consola == null) {
            System.err.println("El modo interactivo requiere un terminal.");
            return CodigoSalida.ERROR_USO;
        }

        System.out.println("BiblioTech 1.0.0 — escribe 'ayuda' o 'salir'.");
        CommandLine cl = new CommandLine(raiz, fabrica);

        while (true) {
            String linea = consola.readLine("bibliotech> ");
            if (linea == null || linea.isBlank()) continue;
            if (linea.equals("salir") || linea.equals("exit")) return CodigoSalida.OK;
            if (linea.equals("ayuda")) { cl.usage(System.out); continue; }

            // Reutiliza EXACTAMENTE el mismo parseo y los mismos comandos
            cl.execute(trocear(linea));
        }
    }

    /** Trocea respetando las comillas: buscar --titulo="Java Efectivo" */
    private String[] trocear(String linea) {
        List<String> partes = new ArrayList<>();
        Matcher m = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(linea);
        while (m.find()) {
            partes.add(m.group(1) != null ? m.group(1) : m.group(2));
        }
        return partes.toArray(String[]::new);
    }
}

Para un REPL serio (historial, edición de línea, autocompletado en vivo), la librería es JLine, que además Picocli integra oficialmente. Aquí basta con lo anterior.

  1. Entrada y salida estándar: componer con tuberías

Esto retoma 01-06 y 06-07, y es lo que convierte una CLI en una pieza del sistema en lugar de una isla.

Los tres flujos, y su regla de uso:

Flujo Java Qué va aquí Se redirige con
stdin (0) System.in Datos de entrada < o |
stdout (1) System.out El resultado, y solo el resultado > o |
stderr (2) System.err Diagnósticos: avisos, progreso, errores 2>

La regla de oro: si algo no forma parte del resultado, no va a System.out. Un mensaje de progreso, una cabecera decorativa o un «Procesando…» en la salida estándar rompen | jq y > fichero.csv.

Leer de la entrada estándar:

@Option(names = "--desde-stdin", description = "Lee los ISBN de la entrada estándar, uno por línea.")
private boolean desdeStdin;

@Parameters(index = "0", arity = "0..1", description = "Fichero de entrada.")
private Path fichero;

private List<String> leerEntrada() throws IOException {
    if (desdeStdin) {
        // Ojo con la codificación: en Java 18+ el defecto es UTF-8, pero ser explícito no sobra
        try (BufferedReader lector = new BufferedReader(
                new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
            return lector.lines()
                    .map(String::strip)
                    .filter(l -> !l.isEmpty() && !l.startsWith("#"))   // ignorar comentarios
                    .toList();
        }
    }
    if (fichero != null) {
        return Files.readAllLines(fichero, StandardCharsets.UTF_8);
    }
    throw new CommandLine.ParameterException(spec.commandLine(),
        "Indica un fichero de entrada o usa --desde-stdin.");
}

Detectar si la salida es un terminal. Esto gobierna colores, progreso y cabeceras:

/** true si stdout va a un terminal; false si va a un fichero o a otro proceso. */
public static boolean esTerminal() {
    return System.console() != null;
}

Con eso, la CLI se adapta sola:

$ bibliotech catalogo listar                      # terminal: colores, cabeceras, totales
$ bibliotech catalogo listar > catalogo.txt       # fichero: sin colores ni adornos
$ bibliotech catalogo listar | grep Java          # tubería: idem

Y el resultado práctico de haber respetado la regla de oro:

# El resultado va al fichero; los avisos siguen viéndose en pantalla
bibliotech catalogo importar datos.csv > resultado.json 2> importacion.log

# Encadenar sin que nada se contamine
bibliotech informe multas --formato=json | jq '[.[] | select(.importe > 10)] | length'

# Usar la salida de un comando como entrada de otro
bibliotech prestamo listar --vencidos --formato=csv | cut -d';' -f2 | \
  bibliotech avisos enviar --desde-stdin

  1. Códigos de salida

Todo proceso devuelve un entero al sistema operativo. Para una persona es invisible; para un script lo es todo:

bibliotech avisos enviar || echo "FALLO: revisa el log"    # || se ejecuta si el código != 0

Convenio de BiblioTech:

Código Constante Significado Reacción típica del script
0 OK Éxito Continuar
1 ERROR Error general de ejecución Abortar y avisar
2 ERROR_USO Argumentos inválidos Corregir la invocación
3 SIN_RESULTADOS Se ejecutó bien, pero no hubo nada Continuar, sin alarma
4 PARCIAL Terminó con errores parciales Revisar el detalle
5 NO_ENCONTRADO El recurso solicitado no existe Depende
6 CONFLICTO Regla de negocio violada No reintentar
7 NO_DISPONIBLE Dependencia externa caída Reintentar más tarde
130 Interrumpido con Ctrl+C Convenio POSIX: 128 + SIGINT(2)
public final class CodigoSalida {
    public static final int OK = 0;
    public static final int ERROR = 1;
    public static final int ERROR_USO = 2;
    public static final int SIN_RESULTADOS = 3;
    public static final int PARCIAL = 4;
    public static final int NO_ENCONTRADO = 5;
    public static final int CONFLICTO = 6;
    public static final int NO_DISPONIBLE = 7;
    public static final int INTERRUMPIDO = 130;

    private CodigoSalida() { }
}

La distinción entre 6 y 7 es la que más valor aporta en la práctica: un script de reintentos debe reintentar ante NO_DISPONIBLE (la API de metadatos está caída) y no ante CONFLICTO (el empleado ya tiene tres préstamos: reintentar mil veces no lo va a arreglar).

#!/usr/bin/env bash
# Reintentar solo cuando tiene sentido
for intento in 1 2 3; do
  bibliotech catalogo importar --enriquecer datos.csv
  codigo=$?
  case $codigo in
    0) echo "Importación correcta"; exit 0 ;;
    7) echo "Servicio no disponible; reintento $intento"; sleep $((intento * 30)) ;;
    *) echo "Error no recuperable (código $codigo)"; exit $codigo ;;
  esac
done
exit 7

  1. Formatear la salida: tabla, CSV y JSON

La clase Salida centraliza todo lo relativo a presentación. Es una fachada (12-02) sobre el formato, el color y la verbosidad.

@Component
public class Salida {

    private final ObjectMapper json;         // Jackson, del módulo 11: bean único
    private final PrintStream out;
    private final PrintStream err;

    private Formato formato = Formato.TABLA;
    private Nivel nivel = Nivel.NORMAL;
    private boolean color = true;

    public Salida(ObjectMapper json) {
        this.json = json;
        // Codificación explícita: sin esto, los acentos se rompen al redirigir en 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 escribirMateriales(List<Material> materiales) {
        switch (formato) {
            case TABLA -> tabla(materiales);
            case CSV   -> csv(materiales);
            case JSON  -> json(materiales.stream().map(MaterialDto::desde).toList());
        }
    }
    // …
}

Formato tabla, con columnas que se ajustan al contenido:

private void tabla(List<Material> materiales) {
    // 1. Calcular el ancho de cada columna: el del contenido más largo, con un máximo
    int anchoTitulo = Math.min(45, Math.max(6,
            materiales.stream().mapToInt(m -> m.getTitulo().length()).max().orElse(6)));

    String formatoFila = "%-17s  %-" + anchoTitulo + "s  %-8s  %5s%n";

    // 2. Cabecera solo si NO es una tubería y no estamos en modo silencioso
    if (mostrarDecoracion()) {
        out.printf(formatoFila, "ISBN", "TÍTULO", "TIPO", "LIBRES");
        out.println("-".repeat(17 + anchoTitulo + 8 + 5 + 6));
    }

    // 3. Filas
    for (Material m : materiales) {
        out.printf(formatoFila,
                m.getIsbn().valor(),
                recortar(m.getTitulo(), anchoTitulo),
                m.tipo(),
                colorearDisponibilidad(m.unidadesDisponibles()));
    }
}

/** Recorta con puntos suspensivos para no descuadrar la tabla. */
private String recortar(String texto, int max) {
    return texto.length() <= max ? texto : texto.substring(0, max - 1) + "…";
}
ISBN               TÍTULO                                TIPO      LIBRES
-----------------------------------------------------------------------
978-0000000001     Java Efectivo                         LIBRO          2
978-0000000002     Patrones de Diseño                    LIBRO          0
978-0000000003     Refactorización                       LIBRO          1

Formato CSV, con el escapado que 07-07 enseñó a no improvisar:

private void csv(List<Material> materiales) {
    if (mostrarDecoracion()) out.println("isbn;titulo;tipo;disponibles");
    for (Material m : materiales) {
        out.printf("%s;%s;%s;%d%n",
                m.getIsbn().valor(), escapar(m.getTitulo()), m.tipo(), m.unidadesDisponibles());
    }
}

private String escapar(String valor) {
    // Si contiene separador, comillas o saltos de línea, hay que entrecomillar y duplicar comillas
    if (valor.contains(";") || valor.contains("\"") || valor.contains("\n")) {
        return '"' + valor.replace("\"", "\"\"") + '"';
    }
    return valor;
}

Formato JSON, con Jackson:

private void json(Object valor) {
    try {
        // Sin indentar si es una tubería (más compacto); indentado si lo lee una persona
        ObjectWriter escritor = esTerminal()
                ? json.writerWithDefaultPrettyPrinter()
                : json.writer();
        out.println(escritor.writeValueAsString(valor));
    } catch (JsonProcessingException e) {
        throw new SalidaFallidaException("No se pudo serializar el resultado", e);
    }
}

Comparativa de uso:

Formato Para quién Cuándo
tabla Personas Uso interactivo (por defecto)
csv Hojas de cálculo, cut, awk Informes, importación en Excel
json jq, otros programas Automatización, integración

  1. Verbosidad: --silencioso y --verboso

Tres niveles, y una regla clara sobre a qué flujo va cada uno:

Nivel Opción Qué se imprime Flujo
Silencioso -q Solo el resultado y los errores out / err
Normal (ninguna) Resultado, cabeceras, totales, avisos out / err
Verboso -v Además, pasos intermedios y tiempos err
Traza -vv Además, el log de la aplicación en DEBUG err
public enum Nivel { SILENCIOSO, NORMAL, VERBOSO, TRAZA }

@Component
public class Salida {

    /** Resultado: SIEMPRE a stdout, incluso en modo silencioso. Es lo que pidió el usuario. */
    public void resultado(String texto) { out.println(texto); }

    /** Información contextual: a stderr, para no contaminar la tubería. */
    public void info(String texto) {
        if (nivel.ordinal() >= Nivel.NORMAL.ordinal()) err.println(texto);
    }

    /** Detalle de ejecución: solo con -v. */
    public void detalle(String texto) {
        if (nivel.ordinal() >= Nivel.VERBOSO.ordinal()) err.println(gris("  " + texto));
    }

    public void aviso(String texto)  { err.println(amarillo("Aviso: ") + texto); }
    public void error(String texto)  { err.println(rojo("Error: ") + texto); }
    public void exito(String texto)  { if (nivel != Nivel.SILENCIOSO) err.println(verde("✓ ") + texto); }
}

El nivel se aplica al arrancar, y -vv sube también el nivel de logging de la aplicación:

@Option(names = {"-v", "--verboso"}, scope = ScopeType.INHERIT)
void setVerboso(boolean[] veces) {
    Nivel nivel = veces.length >= 2 ? Nivel.TRAZA : Nivel.VERBOSO;
    salida.setNivel(nivel);
    if (nivel == Nivel.TRAZA) {
        // Subir el nivel de Logback en caliente (11-07)
        ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.nexussoftware.bibliotech"))
            .setLevel(ch.qos.logback.classic.Level.DEBUG);
    }
}

  1. Operaciones largas: progreso y señales de vida

La importación del catálogo con enriquecimiento consulta la API de metadatos para cada material. Con 5.000 materiales, eso son varios minutos. Sin señales de vida, el usuario asume que se ha colgado y pulsa Ctrl+C.

Aquí se retoma la importación concurrente del módulo 8: un ExecutorService con hilos virtuales (10-06), que para tareas dominadas por E/S es exactamente el caso de uso ideal.

@Command(name = "importar", description = "Importa materiales al catálogo.")
@Component
public class ComandoCatalogoImportar implements Callable<Integer> {

    private final ImportadorCatalogo importador;
    private final Salida salida;

    @Parameters(index = "0", arity = "0..1") private Path fichero;
    @Option(names = "--desde-stdin") private boolean desdeStdin;
    @Option(names = "--enriquecer", description = "Completa los metadatos desde la API externa.")
    private boolean enriquecer;
    @Option(names = "--simular", description = "No escribe nada; muestra lo que haría.")
    private boolean simular;

    @Override
    public Integer call() throws Exception {
        List<RegistroImportacion> registros = leerEntrada();
        salida.info("Importando %d registros%s…"
                .formatted(registros.size(), simular ? " (SIMULACIÓN)" : ""));

        var progreso = new BarraProgreso(registros.size(), salida);
        var correctos = new AtomicInteger();                 // módulo 8
        var errores = Collections.synchronizedList(new ArrayList<ErrorImportacion>());

        // Hilos virtuales: miles de tareas bloqueantes de E/S sin agotar el sistema
        try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (RegistroImportacion registro : registros) {
                ejecutor.submit(() -> {
                    try {
                        if (!simular) importador.importar(registro, enriquecer);
                        correctos.incrementAndGet();
                    } catch (BiblioTechException e) {
                        errores.add(ErrorImportacion.de(registro, e));
                    } finally {
                        progreso.avanzar();
                    }
                });
            }
        }   // el cierre del try-with-resources espera a que TODAS terminen

        progreso.terminar();

        salida.escribirResultadoImportacion(correctos.get(), errores);

        if (errores.isEmpty()) return CodigoSalida.OK;
        if (correctos.get() == 0) return CodigoSalida.ERROR;
        return CodigoSalida.PARCIAL;                          // el 4 de la tabla
    }
}

La barra de progreso, con las tres decisiones que la hacen correcta:

public class BarraProgreso {

    private static final int ANCHO = 40;

    private final int total;
    private final Salida salida;
    private final AtomicInteger actual = new AtomicInteger();
    private final long inicio = System.nanoTime();
    private final boolean activa;
    private volatile long ultimoRepintado;

    public BarraProgreso(int total, Salida salida) {
        this.total = total;
        this.salida = salida;
        // DECISIÓN 1: solo si hay terminal y no estamos en silencioso.
        // Una barra de progreso en un fichero de log es basura ilegible.
        this.activa = salida.esTerminal() && salida.nivel() != Nivel.SILENCIOSO;
    }

    public void avanzar() {
        int n = actual.incrementAndGet();
        if (!activa) return;

        // DECISIÓN 2: limitar el repintado. Repintar 5.000 veces por segundo
        // consume más CPU que el propio trabajo.
        long ahora = System.nanoTime();
        if (n < total && ahora - ultimoRepintado < 100_000_000L) return;   // 100 ms
        ultimoRepintado = ahora;

        pintar(n);
    }

    private void pintar(int n) {
        int llenos = (int) ((double) n / total * ANCHO);
        long segundos = (System.nanoTime() - inicio) / 1_000_000_000L;
        long restantes = n > 0 ? segundos * (total - n) / n : 0;

        // DECISIÓN 3: a stderr, no a stdout. El progreso NO es el resultado.
        // \r vuelve al principio de la línea sin saltar: la barra se sobreescribe.
        salida.err().printf("\r[%s%s] %d/%d (%d%%) ETA %ds  ",
                "=".repeat(llenos), " ".repeat(ANCHO - llenos),
                n, total, n * 100 / total, restantes);
    }

    public void terminar() {
        if (activa) salida.err().println();     // cerrar la línea de la barra
    }
}
[========================>               ] 3120/5000 (62%) ETA 47s

Para operaciones sin total conocido, un girador (|, /, -, \) o un simple punto cada N elementos cumple la misma función: decir «sigo vivo».

  1. Cancelación limpia con Ctrl+C

Ctrl+C envía SIGINT. Por defecto, la JVM termina de inmediato: transacciones a medias, ficheros a medio escribir, contexto de Spring sin cerrar.

La solución es un shutdown hook, que retoma lo visto en el módulo 7 sobre cierre ordenado de recursos:

@Component
public class GestorCancelacion {

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

    private final AtomicBoolean cancelado = new AtomicBoolean(false);
    private final CountDownLatch trabajoTerminado = new CountDownLatch(1);

    @PostConstruct
    void registrar() {
        Runtime.getRuntime().addShutdownHook(new Thread(this::alRecibirSenal, "cancelacion"));
    }

    private void alRecibirSenal() {
        cancelado.set(true);
        System.err.println("\nCancelando… (pulsa Ctrl+C de nuevo para forzar)");
        try {
            // Dar un margen para terminar ordenadamente, pero NO esperar indefinidamente:
            // un hook que no acaba deja el proceso colgado.
            if (!trabajoTerminado.await(10, TimeUnit.SECONDS)) {
                System.err.println("El trabajo no terminó a tiempo; saliendo de todos modos.");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** Los bucles largos consultan esto en cada iteración. */
    public boolean cancelado() { return cancelado.get(); }

    public void trabajoCompletado() { trabajoTerminado.countDown(); }
}

Uso en el comando:

@Override
public Integer call() {
    try {
        for (RegistroImportacion registro : registros) {
            if (cancelacion.cancelado()) {
                salida.aviso("Importación cancelada por el usuario. "
                           + "Procesados %d de %d registros.".formatted(procesados, registros.size()));
                return CodigoSalida.INTERRUMPIDO;      // 130, convenio POSIX
            }
            importador.importar(registro, enriquecer);
            procesados++;
        }
        return CodigoSalida.OK;
    } finally {
        cancelacion.trabajoCompletado();     // libera el hook
    }
}

Tres reglas del shutdown hook que conviene no olvidar:

  1. Debe ser rápido. El sistema puede matar el proceso si tarda demasiado (SIGKILL no se puede interceptar).
  2. No puede depender del contexto de Spring. Puede estar cerrándose ya.
  3. Debe ser idempotente. Un segundo Ctrl+C no debe romper nada.

  1. Colores ANSI y cuándo desactivarlos

Los colores se producen con secuencias de escape ANSI:

public final class Ansi {
    public static final String RESET   = "";
    public static final String ROJO    = "";
    public static final String VERDE   = "";
    public static final String AMARILLO= "";
    public static final String GRIS    = "";
    public static final String NEGRITA = "";
}

El problema es que, si la salida no va a un terminal, esas secuencias se escriben literalmente:

$ bibliotech catalogo listar > salida.txt
$ cat salida.txt
^[[32m978-0000000001^[[0m  Java Efectivo …

Lógica de decisión, en orden de precedencia:

public class DetectorColor {

    public static boolean debeUsarColor(boolean opcionSinColor) {
        // 1. La opción explícita del usuario manda
        if (opcionSinColor) return false;

        // 2. NO_COLOR: convenio universal (no-color.org). Si existe, con cualquier valor, se respeta
        if (System.getenv("NO_COLOR") != null) return false;

        // 3. FORCE_COLOR: para forzar colores en un pipeline de CI que sí los renderiza
        if (System.getenv("FORCE_COLOR") != null) return true;

        // 4. Terminal "tonto" (algunos entornos de CI, Emacs shell)
        String term = System.getenv("TERM");
        if ("dumb".equals(term)) return false;

        // 5. Sin terminal (tubería o redirección): sin color
        return System.console() != null;
    }
}
Condición Color
--sin-color No
NO_COLOR definida No
FORCE_COLOR definida
TERM=dumb No
Salida redirigida o en tubería No
Terminal interactivo

Y dos consejos de uso: el color nunca debe ser el único portador de información (por accesibilidad y por daltonismo: acompáñalo de un símbolo o una palabra), y menos es más — rojo para errores, amarillo para avisos, verde para éxito, gris para lo secundario. Nada más.

  1. Errores orientados al usuario

Una traza de pila en la consola de un usuario es una confesión de que no se ha pensado en él. La estructura de un buen error tiene cuatro partes:

Error: no se puede prestar "Java Efectivo" a Diego Alonso.

  Causa:      el empleado ya tiene 3 préstamos activos (máximo permitido: 3).
  Sugerencia: consulta sus préstamos con
              bibliotech prestamo listar --empleado=2
              y devuelve alguno antes de crear uno nuevo.

  Detalle técnico registrado con id: a7f3e91c

El manejador centralizado de Picocli, que traduce la jerarquía BiblioTechException del módulo 6:

public class ManejadorErroresCli implements CommandLine.IExecutionExceptionHandler {

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

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

        String idIncidencia = UUID.randomUUID().toString().substring(0, 8);
        // El detalle técnico COMPLETO va al log, no a la pantalla del usuario (06-07)
        log.error("Error en la ejecución del comando [id={}]", idIncidencia, e);

        PrintWriter err = cl.getErr();

        return switch (e) {
            case MaterialNoEncontradoException ex -> {
                err.println(rojo("Error: ") + "no existe ningún material con ISBN " + ex.getIsbn() + ".");
                err.println("  Sugerencia: búscalo con 'bibliotech catalogo buscar --titulo=\"...\"'");
                yield CodigoSalida.NO_ENCONTRADO;
            }
            case LimiteDePrestamosExcedidoException ex -> {
                err.println(rojo("Error: ") + "el empleado " + ex.getNombre()
                          + " ya tiene " + ex.getMaximo() + " préstamos activos.");
                err.println("  Sugerencia: 'bibliotech prestamo listar --empleado=" + ex.getId() + "'");
                yield CodigoSalida.CONFLICTO;
            }
            case ServicioExternoNoDisponibleException ex -> {
                err.println(rojo("Error: ") + "el servicio de metadatos no responde.");
                err.println("  Sugerencia: reintenta más tarde, o usa --sin-enriquecer.");
                yield CodigoSalida.NO_DISPONIBLE;      // 7: el script SÍ debe reintentar
            }
            case BiblioTechException ex -> {
                err.println(rojo("Error: ") + ex.getMessage());
                yield CodigoSalida.ERROR;
            }
            default -> {
                // Lo inesperado: mensaje genérico + identificador para correlacionar con el log
                err.println(rojo("Error inesperado. ") + "Incidencia " + idIncidencia + ".");
                err.println("  Ejecuta con -vv para ver el detalle, o envía ese identificador a soporte.");
                yield CodigoSalida.ERROR;
            }
        };
    }
}

El switch con patrones sobre tipos es el pattern matching de Java 21 (10-06), y aquí luce especialmente: sustituye una escalera de instanceof.

El identificador de incidencia es el detalle que más agradece el soporte técnico: el usuario ve ocho caracteres, y con ellos se localiza la traza completa en el log (que ya está correlacionado con MDC desde 11-07).

  1. Empaquetado y distribución

Jar ejecutable con 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.consola.BiblioTechCli</mainClass>
        <executable>true</executable>   <!-- añade un script de arranque al propio jar -->
      </configuration>
      <executions>
        <execution><goals><goal>repackage</goal></goals></execution>
      </executions>
    </plugin>
  </plugins>
</build>
./mvnw -pl bibliotech-consola clean package
java -jar bibliotech-consola/target/bibliotech-cli.jar catalogo listar

Script de arranque, para que se invoque como bibliotech y no como java -jar …:

#!/usr/bin/env bash
# scripts/bibliotech — instálalo en /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}"

# Opciones de JVM pensadas para arranque rápido, no para un servidor de larga vida:
#  -XX:TieredStopAtLevel=1  no compilar a fondo: el proceso dura segundos
#  -XX:+UseSerialGC         el GC más barato de inicializar
#  -Xshare:auto             usar el archivo de clases compartidas
exec "$JAVA_BIN" \
  -XX:TieredStopAtLevel=1 \
  -XX:+UseSerialGC \
  -Xshare:auto \
  -Dfile.encoding=UTF-8 \
  ${BIBLIOTECH_OPTS:-} \
  -jar "$BIBLIOTECH_HOME/bibliotech-cli.jar" "$@"      # "$@" preserva los argumentos con espacios

Ese "$@" con comillas es importante: sin ellas, bibliotech catalogo buscar --titulo="Java Efectivo" se rompe en dos argumentos.

El problema del arranque. Una CLI con Spring Boot tarda entre 1 y 3 segundos en arrancar. Para un uso ocasional es tolerable; para un comando que se invoca mil veces en un bucle, no.

Opción Arranque Coste
Jar normal 1-3 s Ninguno
CDS (-XX:SharedArchiveFile) 0,7-2 s Un paso extra en la construcción
AppCDS + -Xshare ~0,6 s Idem
jpackage (instalador nativo con JRE incluido) Igual que el jar No hace falta Java instalado
GraalVM Native Image ~0,05 s Construcción lenta; reflexión hay que declararla

jpackage (incluido en el JDK desde Java 14) produce un .deb, .rpm, .msi o .dmg con la JRE dentro:

jpackage --type deb \
  --name bibliotech \
  --input bibliotech-consola/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 es la opción cuando el arranque instantáneo importa de verdad. Picocli tiene soporte de primera clase (genera los metadatos de reflexión automáticamente), y Spring Boot 3 también:

./mvnw -pl bibliotech-consola -Pnative native:compile
./bibliotech-consola/target/bibliotech catalogo listar     # arranca en ~50 ms

El precio: la construcción tarda varios minutos, y todo lo que use reflexión (Jackson, JPA) necesita metadatos declarados. Para una CLI pequeña compensa; para una aplicación grande hay que valorarlo.

  1. Probar una aplicación de consola

Es la parte que casi todos los proyectos se saltan, y no hay motivo: una CLI se prueba en tres niveles.

Nivel 1: la lógica del comando, sin Picocli. El comando es un bean normal; se prueba con Mockito (11-06):

@ExtendWith(MockitoExtension.class)
class ComandoPrestamoCrearTest {

    @Mock GestionarPrestamos gestor;
    @Mock Salida salida;
    @InjectMocks ComandoPrestamoCrear comando;

    @Test
    void devuelveOkYAvisaCuandoElPrestamoSeCrea() {
        var prestamo = unPrestamoDe("978-0000000001", "Marta Ruiz");
        when(gestor.prestar(any(), eq(1L), isNull())).thenReturn(prestamo);
        ReflectionTestUtils.setField(comando, "isbn", Isbn.de("978-0000000001"));
        ReflectionTestUtils.setField(comando, "idEmpleado", 1L);

        Integer codigo = comando.call();

        assertThat(codigo).isEqualTo(CodigoSalida.OK);
        verify(salida).exito(contains("Préstamo #" + prestamo.getId()));
    }
}

Nivel 2: el parseo de argumentos. Se comprueba que las opciones se convierten bien, sin ejecutar nada:

class ParseoArgumentosTest {

    @Test
    void parseaLasOpcionesDelComandoListar() {
        var comando = new ComandoCatalogoListar(mock(ConsultarCatalogo.class), mock(Salida.class));
        var cl = new CommandLine(comando);

        cl.parseArgs("--tipo=LIBRO", "--disponibles", "--limite=10");

        assertThat(ReflectionTestUtils.getField(comando, "tipo")).isEqualTo(TipoMaterial.LIBRO);
        assertThat(ReflectionTestUtils.getField(comando, "soloDisponibles")).isEqualTo(true);
        assertThat(ReflectionTestUtils.getField(comando, "limite")).isEqualTo(10);
    }

    @Test
    void rechazaUnIsbnInvalidoConMensajeUtil() {
        var cl = new CommandLine(new ComandoPrestamoCrear(mock(…), mock(…)));

        assertThatThrownBy(() -> cl.parseArgs("--isbn=1234", "--empleado=1"))
            .isInstanceOf(CommandLine.ParameterException.class)
            .hasMessageContaining("no es un ISBN-13 válido");
    }

    @Test
    void exigeLasOpcionesObligatorias() {
        var cl = new CommandLine(new ComandoPrestamoCrear(mock(…), mock(…)));

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

Nivel 3: extremo a extremo, capturando la salida. Se ejecuta el comando completo y se comprueba lo que escribe y qué código devuelve:

@SpringBootTest
class BiblioTechCliIT {

    @Autowired ComandoRaiz raiz;
    @Autowired CommandLine.IFactory fabrica;

    @Test
    void listarCatalogoEnJsonProduceJsonValido() throws Exception {
        var salidaCapturada = new StringWriter();
        var errorCapturado = new StringWriter();

        int codigo = new CommandLine(raiz, fabrica)
                .setOut(new PrintWriter(salidaCapturada))     // Picocli permite redirigir
                .setErr(new PrintWriter(errorCapturado))
                .execute("catalogo", "listar", "--formato=json");

        assertThat(codigo).isEqualTo(CodigoSalida.OK);

        // El resultado debe ser JSON VÁLIDO: nada de banners ni mensajes contaminándolo
        JsonNode arbol = new ObjectMapper().readTree(salidaCapturada.toString());
        assertThat(arbol.isArray()).isTrue();
        assertThat(arbol).hasSize(3);
        assertThat(arbol.get(0).get("titulo").asText()).isEqualTo("Java Efectivo");
    }

    @Test
    void devuelveCodigo5CuandoElMaterialNoExiste() {
        int codigo = new CommandLine(raiz, fabrica)
                .setExecutionExceptionHandler(new ManejadorErroresCli())
                .execute("catalogo", "ficha", "978-9999999999");

        assertThat(codigo).isEqualTo(CodigoSalida.NO_ENCONTRADO);
    }

    @Test
    void devuelveCodigo2CuandoFaltaUnaOpcionObligatoria() {
        int codigo = new CommandLine(raiz, fabrica).execute("prestamo", "crear", "--empleado=1");
        assertThat(codigo).isEqualTo(2);      // error de uso, el que Picocli usa por defecto
    }
}

La prueba de que el JSON es válido es especialmente valiosa: detecta al instante que alguien ha metido un System.out.println("Procesando…") donde no debía.

Errores Comunes y Consejos

1. Mezclar resultado y diagnósticos en System.out. Es el error más frecuente y el que más rompe la automatización. Un solo println de progreso invalida | jq. Regla: si no es el resultado, va a System.err.

2. Devolver siempre 0. Un script no puede distinguir el éxito del fracaso, y un fallo nocturno pasa inadvertido durante semanas. Devuelve códigos con significado.

3. Imprimir trazas de pila al usuario. El usuario no puede hacer nada con NullPointerException. La traza va al log; a la pantalla va un mensaje con causa, sugerencia e identificador de incidencia.

4. No detectar si hay terminal. Colores, barras de progreso y cabeceras decorativas deben desactivarse solos cuando la salida se redirige. Comprueba System.console() != null.

5. Ignorar NO_COLOR. Es un convenio establecido. Si no lo respetas, tu herramienta será la que estropee la salida en el terminal de alguien.

6. Barras de progreso que repintan sin control. Repintar 5.000 veces por segundo consume más CPU que el trabajo real y satura el terminal. Limita a un repintado cada 100 ms.

7. Olvidar la codificación. Sin -Dfile.encoding=UTF-8 o PrintStream explícito, «Refactorización» sale como «Refactorizaci?n» al redirigir en algunos sistemas.

8. Un --help que no ayuda. «Muestra materiales» no explica nada. Escribe descripciones útiles y añade ejemplos en el footer: es lo primero que se lee y lo último que se escribe.

9. Operaciones destructivas sin confirmación ni simulación. Todo comando que borra o modifica en masa debe tener --simular y, en modo interactivo, pedir confirmación. En modo no interactivo, --si para saltarla.

10. Duplicar lógica entre CLI y web. Si ComandoPrestamoCrear valida reglas de negocio que el controlador REST también valida, tarde o temprano divergen. Ambos deben limitarse a llamar al mismo caso de uso.

Consejo final: prueba tu CLI dentro de una tubería desde el primer día. bibliotech catalogo listar --formato=json | jq . detecta al instante casi todos los errores de esta lista.

Ejercicios

Ejercicio 1: comando prestamo devolver completo

Implementa bibliotech prestamo devolver con:

  • Un parámetro posicional obligatorio: el identificador del préstamo.
  • --fecha opcional (por defecto, hoy), validando que no sea futura.
  • --simular: calcula y muestra la multa sin registrar la devolución.
  • Salida en los tres formatos.
  • Códigos: 0 correcto, 5 préstamo no encontrado, 6 ya devuelto, 2 fecha inválida.
  • Un mensaje de éxito que indique la multa si la hay.

Ejercicio 2: entrada estándar y códigos de salida

Implementa bibliotech avisos enviar que:

  • Por defecto, envía avisos de los préstamos que vencen en --dias-antelacion días (por defecto 3).
  • Con --desde-stdin, lee identificadores de empleado de la entrada estándar (uno por línea) y avisa solo a esos.
  • Tiene --simular para mostrar a quién se avisaría sin enviar nada.
  • Devuelve 0 si se enviaron todos, 3 si no había nada que enviar, 4 si algunos fallaron y 7 si el servidor de correo no responde.
  • Muestra progreso solo si hay terminal.

Escribe además el script de shell que lo usaría en un cron con reintentos.

Ejercicio 3: formateador de tabla reutilizable

Escribe una clase TablaConsola genérica que:

  • Acepte columnas con nombre, una función extractora y una alineación.
  • Calcule los anchos automáticamente, con un máximo por columna y recorte con «…».
  • Soporte separador de cabecera y totales opcionales al pie.
  • Se adapte al ancho del terminal si es posible.
  • Sea usable así:
TablaConsola.de(materiales)
    .columna("ISBN", m -> m.getIsbn().valor())
    .columna("TÍTULO", Material::getTitulo, 45)
    .columna("TIPO", m -> m.tipo().name())
    .columnaNumerica("LIBRES", m -> m.unidadesDisponibles())
    .conTotales()
    .imprimir(salida);

Soluciones

Solución 1

@Command(name = "devolver",
    description = "Registra la devolución de un préstamo.",
    footerHeading = "%nEjemplos:%n",
    footer = {
        "  bibliotech prestamo devolver 42",
        "  bibliotech prestamo devolver 42 --fecha=2026-03-15",
        "  bibliotech prestamo devolver 42 --simular --formato=json"
    })
@Component
public class ComandoPrestamoDevolver implements Callable<Integer> {

    private final GestionarPrestamos gestor;
    private final CalculadoraMultas calculadora;
    private final Salida salida;
    private final Clock reloj;

    @Spec CommandLine.Model.CommandSpec spec;

    public ComandoPrestamoDevolver(GestionarPrestamos gestor, CalculadoraMultas calculadora,
                                   Salida salida, Clock reloj) {
        this.gestor = gestor;
        this.calculadora = calculadora;
        this.salida = salida;
        this.reloj = reloj;
    }

    @Parameters(index = "0", paramLabel = "<idPrestamo>",
                description = "Identificador del préstamo a devolver.")
    private Long idPrestamo;

    @Option(names = "--fecha", description = "Fecha de devolución (yyyy-MM-dd). Por defecto, hoy.")
    private LocalDate fecha;

    @Option(names = "--simular",
            description = "Calcula la multa sin registrar la devolución.")
    private boolean simular;

    @Override
    public Integer call() {
        LocalDate hoy = LocalDate.now(reloj);
        LocalDate fechaEfectiva = (fecha != null) ? fecha : hoy;

        // Validación cruzada: ParameterException produce mensaje + uso + código 2
        if (fechaEfectiva.isAfter(hoy)) {
            throw new CommandLine.ParameterException(spec.commandLine(),
                "--fecha (%s) no puede ser posterior a hoy (%s)."
                    .formatted(fechaEfectiva, hoy));
        }

        Prestamo prestamo = gestor.buscar(idPrestamo)
                .orElseThrow(() -> new PrestamoNoEncontradoException(idPrestamo));

        if (prestamo.getFechaDevolucion().isPresent()) {
            // La excepción la traduce ManejadorErroresCli a código 6 (CONFLICTO)
            throw new PrestamoYaDevueltoException(idPrestamo,
                    prestamo.getFechaDevolucion().orElseThrow());
        }

        if (fechaEfectiva.isBefore(prestamo.getFechaPrestamo())) {
            throw new CommandLine.ParameterException(spec.commandLine(),
                "--fecha (%s) es anterior a la fecha del préstamo (%s)."
                    .formatted(fechaEfectiva, prestamo.getFechaPrestamo()));
        }

        if (simular) {
            Dinero multa = calculadora.calcular(prestamo, fechaEfectiva);
            salida.escribirDevolucion(new ResultadoDevolucion(
                    prestamo.getId(), prestamo.tituloDelMaterial(), prestamo.nombreDelEmpleado(),
                    fechaEfectiva, multa, true));
            salida.aviso("SIMULACIÓN: no se ha registrado nada.");
            return CodigoSalida.OK;
        }

        ResultadoDevolucion resultado = gestor.devolver(idPrestamo, fechaEfectiva);
        salida.escribirDevolucion(resultado);

        if (resultado.multa().esPositiva()) {
            salida.exito("Devolución registrada. Multa: %s (%d días de retraso)."
                    .formatted(resultado.multa(), resultado.diasDeRetraso()));
        } else {
            salida.exito("Devolución registrada en plazo. Sin multa.");
        }
        return CodigoSalida.OK;
    }
}

El formateo, en Salida:

public void escribirDevolucion(ResultadoDevolucion r) {
    switch (formato) {
        case TABLA -> {
            out.printf("%-14s %s%n", "Préstamo:", "#" + r.idPrestamo());
            out.printf("%-14s %s%n", "Material:", r.tituloMaterial());
            out.printf("%-14s %s%n", "Empleado:", r.nombreEmpleado());
            out.printf("%-14s %s%n", "Devolución:", r.fecha());
            out.printf("%-14s %s%n", "Multa:",
                    r.multa().esPositiva() ? rojo(r.multa().toString()) : verde("sin multa"));
        }
        case CSV -> {
            if (mostrarDecoracion()) out.println("id;material;empleado;fecha;multa");
            out.printf("%d;%s;%s;%s;%s%n", r.idPrestamo(), escapar(r.tituloMaterial()),
                    escapar(r.nombreEmpleado()), r.fecha(), r.multa().importe());
        }
        case JSON -> json(r);
    }
}

Comprobación de los códigos de salida:

$ bibliotech prestamo devolver 42;              echo $?   # 0
$ bibliotech prestamo devolver 9999;            echo $?   # 5 (no encontrado)
$ bibliotech prestamo devolver 42;              echo $?   # 6 (ya devuelto)
$ bibliotech prestamo devolver 42 --fecha=2099-01-01; echo $?   # 2 (uso incorrecto)

Solución 2

@Command(name = "enviar",
    description = "Envía avisos de vencimiento próximo a los empleados afectados.",
    footerHeading = "%nCódigos de salida:%n",
    footer = {
        "  0  todos los avisos se enviaron",
        "  3  no había ningún aviso que enviar",
        "  4  algunos avisos fallaron",
        "  7  el servidor de correo no responde"
    })
@Component
public class ComandoAvisosEnviar implements Callable<Integer> {

    private final ServicioAvisos avisos;
    private final RepositorioPrestamos prestamos;
    private final Salida salida;
    private final GestorCancelacion cancelacion;
    private final Clock reloj;

    @Option(names = "--dias-antelacion", defaultValue = "3",
            description = "Avisar de los vencimientos en N días. Por defecto: ${DEFAULT-VALUE}")
    private int diasAntelacion;

    @Option(names = "--desde-stdin",
            description = "Lee identificadores de empleado de la entrada estándar, uno por línea.")
    private boolean desdeStdin;

    @Option(names = "--simular", description = "Muestra a quién se avisaría, sin enviar nada.")
    private boolean simular;

    @Override
    public Integer call() throws IOException {
        List<Prestamo> objetivo = seleccionarPrestamos();

        if (objetivo.isEmpty()) {
            salida.info("No hay ningún préstamo que requiera aviso.");
            return CodigoSalida.SIN_RESULTADOS;          // 3: NO es un error
        }

        salida.info("Preparando %d avisos%s…".formatted(objetivo.size(), simular ? " (SIMULACIÓN)" : ""));

        var progreso = new BarraProgreso(objetivo.size(), salida);   // se autodesactiva sin terminal
        int enviados = 0;
        List<FalloEnvio> fallos = new ArrayList<>();

        for (Prestamo p : objetivo) {
            if (cancelacion.cancelado()) {
                progreso.terminar();
                salida.aviso("Cancelado. Enviados %d de %d.".formatted(enviados, objetivo.size()));
                return CodigoSalida.INTERRUMPIDO;
            }
            try {
                if (simular) {
                    // El resultado de la simulación SÍ es resultado: va a stdout
                    salida.resultado("%s <%s> — \"%s\" vence el %s"
                            .formatted(p.nombreDelEmpleado(), p.correoDelEmpleado(),
                                       p.tituloDelMaterial(), p.getFechaVencimiento()));
                } else {
                    avisos.enviar(Aviso.porVencimiento(p));
                }
                enviados++;
            } catch (ServicioExternoNoDisponibleException e) {
                // El servidor de correo caído afecta a TODOS: no tiene sentido seguir
                progreso.terminar();
                throw e;                                  // el manejador lo traduce a 7
            } catch (BiblioTechException e) {
                fallos.add(new FalloEnvio(p.getId(), p.correoDelEmpleado(), e.getMessage()));
            } finally {
                progreso.avanzar();
            }
        }
        progreso.terminar();

        if (!fallos.isEmpty()) {
            salida.aviso("%d avisos fallaron:".formatted(fallos.size()));
            fallos.forEach(f -> salida.aviso("  préstamo #%d (%s): %s"
                    .formatted(f.idPrestamo(), f.destino(), f.motivo())));
            salida.info("Enviados %d de %d.".formatted(enviados, objetivo.size()));
            return CodigoSalida.PARCIAL;                  // 4
        }

        salida.exito("Enviados %d avisos.".formatted(enviados));
        return CodigoSalida.OK;
    }

    private List<Prestamo> seleccionarPrestamos() throws IOException {
        LocalDate limite = LocalDate.now(reloj).plusDays(diasAntelacion);

        if (!desdeStdin) {
            return prestamos.activosQueVencenAntesDe(limite);
        }

        List<Long> ids = leerIdsDeStdin();
        if (ids.isEmpty()) {
            salida.aviso("No se leyó ningún identificador de la entrada estándar.");
            return List.of();
        }
        salida.detalle("Filtrando por %d empleados leídos de stdin".formatted(ids.size()));
        return prestamos.activosQueVencenAntesDe(limite).stream()
                .filter(p -> ids.contains(p.getIdEmpleado()))
                .toList();
    }

    private List<Long> leerIdsDeStdin() throws IOException {
        try (var lector = new BufferedReader(new InputStreamReader(System.in, UTF_8))) {
            return lector.lines()
                    .map(String::strip)
                    .filter(l -> !l.isEmpty() && !l.startsWith("#"))
                    .map(l -> {
                        try {
                            return Long.parseLong(l);
                        } catch (NumberFormatException e) {
                            salida.aviso("Se ignora la línea no numérica: '%s'".formatted(l));
                            return null;
                        }
                    })
                    .filter(Objects::nonNull)
                    .distinct()
                    .toList();
        }
    }
}

Uso y composición:

# Todos los vencimientos de los próximos 3 días
bibliotech avisos enviar

# Solo a los empleados del departamento de Arquitectura
bibliotech empleado listar --departamento=Arquitectura --formato=csv \
  | cut -d';' -f1 | tail -n +2 \
  | bibliotech avisos enviar --desde-stdin --dias-antelacion=7

# Ver a quién se avisaría, sin enviar nada
bibliotech avisos enviar --simular --formato=json | jq -r '.[].correo'

Script para cron:

#!/usr/bin/env bash
# /opt/bibliotech/scripts/avisos-diarios.sh
# Ejecutar con: 0 8 * * * /opt/bibliotech/scripts/avisos-diarios.sh
set -uo pipefail                    # sin -e: queremos gestionar los códigos nosotros

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

for intento in $(seq 1 $MAX_INTENTOS); do
  # El resultado al log; los diagnósticos también, pero separables por si acaso
  bibliotech avisos enviar --dias-antelacion=3 --silencioso >>"$LOG" 2>&1
  codigo=$?

  case $codigo in
    0) echo "$(date -Is) OK: avisos enviados" >>"$LOG"; exit 0 ;;
    3) echo "$(date -Is) Nada que enviar hoy"  >>"$LOG"; exit 0 ;;   # NO es un fallo
    4) echo "$(date -Is) AVISO: envío parcial; revisa el log" >>"$LOG"
       mail -s "BiblioTech: avisos parciales" [email protected] <"$LOG"
       exit 0 ;;
    7) echo "$(date -Is) Correo no disponible; reintento $intento de $MAX_INTENTOS" >>"$LOG"
       sleep $((intento * 120)) ;;                                   # 2, 4, 6 minutos
    *) echo "$(date -Is) ERROR irrecuperable (código $codigo)" >>"$LOG"
       mail -s "BiblioTech: fallo en avisos" [email protected] <"$LOG"
       exit "$codigo" ;;
  esac
done

echo "$(date -Is) ERROR: agotados los reintentos" >>"$LOG"
mail -s "BiblioTech: correo caído tras 3 intentos" [email protected] <"$LOG"
exit 7

Fíjate en la decisión de diseño que hace útil todo esto: el código 3 no dispara alarma (que un martes no haya vencimientos es normal) y el 7 sí dispara reintento. Con un único código de error genérico, este script no se podría escribir.

Solución 3

/**
 * Formateador de tablas para consola, con anchos automáticos.
 * Uso:
 *   TablaConsola.de(materiales)
 *       .columna("ISBN", m -> m.getIsbn().valor())
 *       .columna("TÍTULO", Material::getTitulo, 45)
 *       .columnaNumerica("LIBRES", Material::unidadesDisponibles)
 *       .conTotales()
 *       .imprimir(salida);
 */
public class TablaConsola<T> {

    private static final int ANCHO_TERMINAL_POR_DEFECTO = 120;
    private static final String SEPARADOR = "  ";

    private final List<T> filas;
    private final List<Columna<T>> columnas = new ArrayList<>();
    private boolean totales = false;

    private TablaConsola(List<T> filas) { this.filas = filas; }

    public static <T> TablaConsola<T> de(List<T> filas) { return new TablaConsola<>(filas); }

    // --- Definición de columnas ---

    public TablaConsola<T> columna(String titulo, Function<T, String> extractor) {
        return columna(titulo, extractor, Integer.MAX_VALUE);
    }

    public TablaConsola<T> columna(String titulo, Function<T, String> extractor, int anchoMaximo) {
        columnas.add(new Columna<>(titulo, extractor, Alineacion.IZQUIERDA, anchoMaximo, null));
        return this;
    }

    public TablaConsola<T> columnaNumerica(String titulo, ToLongFunction<T> extractor) {
        columnas.add(new Columna<>(titulo,
                t -> String.valueOf(extractor.applyAsLong(t)),
                Alineacion.DERECHA, 15, extractor));
        return this;
    }

    public TablaConsola<T> conTotales() { this.totales = true; return this; }

    // --- Impresión ---

    public void imprimir(Salida salida) {
        if (columnas.isEmpty()) throw new IllegalStateException("Define al menos una columna");

        int[] anchos = calcularAnchos();
        ajustarAlTerminal(anchos);

        PrintStream out = salida.out();

        if (salida.mostrarDecoracion()) {
            out.println(fila(anchos, i -> columnas.get(i).titulo()));
            out.println("-".repeat(anchoTotal(anchos)));
        }

        for (T elemento : filas) {
            out.println(fila(anchos, i -> valor(columnas.get(i), elemento, anchos[i])));
        }

        if (totales && salida.mostrarDecoracion()) {
            out.println("-".repeat(anchoTotal(anchos)));
            out.println(fila(anchos, this::totalDeColumna));
            out.printf("%d filas%n", filas.size());
        }
    }

    // --- Cálculo de anchos ---

    private int[] calcularAnchos() {
        int[] anchos = new int[columnas.size()];
        for (int i = 0; i < columnas.size(); i++) {
            Columna<T> c = columnas.get(i);
            int anchoContenido = filas.stream()
                    .map(c.extractor())
                    .mapToInt(String::length)
                    .max().orElse(0);
            // El ancho es el mayor entre el título y el contenido, limitado por el máximo
            anchos[i] = Math.min(c.anchoMaximo(), Math.max(c.titulo().length(), anchoContenido));
        }
        return anchos;
    }

    /**
     * Si la tabla no cabe, recorta proporcionalmente las columnas de texto
     * más anchas, respetando un mínimo de 8 caracteres.
     */
    private void ajustarAlTerminal(int[] anchos) {
        int disponible = anchoTerminal();
        int total = anchoTotal(anchos);
        if (total <= disponible) return;

        int exceso = total - disponible;
        // Recortar de mayor a menor hasta absorber el exceso
        List<Integer> candidatas = IntStream.range(0, anchos.length)
                .boxed()
                .filter(i -> columnas.get(i).alineacion() == Alineacion.IZQUIERDA)
                .sorted(Comparator.comparingInt((Integer i) -> anchos[i]).reversed())
                .toList();

        for (int i : candidatas) {
            if (exceso <= 0) break;
            int recortable = Math.max(0, anchos[i] - 8);
            int recorte = Math.min(recortable, exceso);
            anchos[i] -= recorte;
            exceso -= recorte;
        }
    }

    private int anchoTerminal() {
        // COLUMNS la exporta el shell; si no está, usar el valor por defecto
        String columnas = System.getenv("COLUMNS");
        try {
            return columnas != null ? Integer.parseInt(columnas) : ANCHO_TERMINAL_POR_DEFECTO;
        } catch (NumberFormatException e) {
            return ANCHO_TERMINAL_POR_DEFECTO;
        }
    }

    private int anchoTotal(int[] anchos) {
        return Arrays.stream(anchos).sum() + SEPARADOR.length() * (anchos.length - 1);
    }

    // --- Formato de celdas ---

    private String fila(int[] anchos, IntFunction<String> celda) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < anchos.length; i++) {
            if (i > 0) sb.append(SEPARADOR);
            String texto = recortar(celda.apply(i), anchos[i]);
            sb.append(columnas.get(i).alineacion() == Alineacion.DERECHA
                    ? " ".repeat(anchos[i] - texto.length()) + texto
                    : texto + " ".repeat(anchos[i] - texto.length()));
        }
        return sb.toString().stripTrailing();     // sin espacios al final: ensucian al copiar
    }

    private String valor(Columna<T> c, T elemento, int ancho) {
        return recortar(c.extractor().apply(elemento), ancho);
    }

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

    private String totalDeColumna(int i) {
        Columna<T> c = columnas.get(i);
        if (c.sumador() == null) return i == 0 ? "TOTAL" : "";
        long suma = filas.stream().mapToLong(c.sumador()).sum();
        return String.valueOf(suma);
    }

    // --- Tipos auxiliares ---

    private enum Alineacion { IZQUIERDA, DERECHA }

    private record Columna<T>(String titulo,
                              Function<T, String> extractor,
                              Alineacion alineacion,
                              int anchoMaximo,
                              ToLongFunction<T> sumador) { }
}

Uso y salida:

TablaConsola.de(materiales)
    .columna("ISBN", m -> m.getIsbn().valor())
    .columna("TÍTULO", Material::getTitulo, 45)
    .columna("TIPO", m -> m.tipo().name())
    .columnaNumerica("LIBRES", Material::unidadesDisponibles)
    .conTotales()
    .imprimir(salida);
ISBN            TÍTULO                          TIPO     LIBRES
---------------------------------------------------------------
978-0000000001  Java Efectivo                   LIBRO         2
978-0000000002  Patrones de Diseño              LIBRO         0
978-0000000003  Refactorización                 LIBRO         1
---------------------------------------------------------------
TOTAL                                                         3
3 filas

Ventajas del diseño: es genérico (sirve para materiales, préstamos, empleados o cualquier cosa), usa el Builder de 12-02, respeta mostrarDecoracion() de modo que en una tubería solo salen los datos, y se adapta al ancho del terminal sin descuadrarse.

Conclusión

BiblioTech ya se puede usar.

Tienes claro cuándo una CLI es la interfaz correcta —tareas repetibles, automatizables o para gente técnica— y las cuatro propiedades que la hacen buena: predecible, componible, con errores útiles y honesta sobre lo que hace. Y sabes por qué el menú con Scanner del módulo 2 no era esto: aquello lo usaba una persona; esto lo usa una persona y un script.

Viste los límites reales del parseo a mano —la forma --opcion=valor, las opciones agrupadas, la conversión de tipos, el ++i que revienta con el índice— y por eso adoptaste Picocli: anotaciones para comandos, opciones y posicionales; conversión automática a más de cuarenta tipos incluidos los de java.time; conversores propios que hacen que el campo sea Isbn y no String, con el error apareciendo en el parseo y no a mitad de ejecución; grupos exclusivos; ayuda generada con ejemplos en el pie; y autocompletado para bash y zsh por una línea de configuración.

Lo integraste con Spring Boot de la forma correcta: los comandos son beans que reciben los casos de uso por constructor, sin Service Locator, con web-application-type: none y banner-mode: off —porque un banner ASCII rompe una tubería de JSON—. Y diseñaste la superficie completa de la herramienta, con subcomandos por área, opciones heredadas con ScopeType.INHERIT, y comandos que no imprimen nada por su cuenta: delegan en Salida.

Interiorizaste la regla que separa una CLI profesional de un programa que escribe cosas: el resultado va a la salida estándar; todo lo demás, a la de error. De ahí salen las tuberías que funcionan, las redirecciones que no se contaminan y las pruebas que verifican que el JSON es JSON válido. Y le pusiste al lado los códigos de salida con significado, con la distinción que de verdad importa —CONFLICTO no se reintenta, NO_DISPONIBLE sí—, que es lo que permite escribir el script de cron con reintentos del ejercicio 2.

Formateas la salida en tabla con anchos calculados, en CSV con el escapado que 07-07 enseñó a no improvisar y en JSON con el Jackson del módulo 11, con tres niveles de verbosidad y decoración que desaparece sola cuando la salida no va a un terminal. Gestionas operaciones largas con una barra de progreso que se pinta en la salida de error, se limita a un repintado cada 100 ms y se desactiva sin terminal; y con hilos virtuales de Java 21 para la importación concurrente que empezó en el módulo 8. Cancelas limpiamente con Ctrl+C mediante un shutdown hook que retoma el cierre ordenado del módulo 7, devolviendo el 130 del convenio POSIX. Usas colores respetando NO_COLOR, TERM=dumb y la ausencia de terminal, sin que el color sea nunca el único portador de información.

Tus errores tienen cuatro partes —qué, por qué, qué hacer y un identificador de incidencia— traducidos desde la jerarquía BiblioTechException del módulo 6 con el pattern matching de Java 21, dejando la traza completa en el log correlacionado con MDC de 11-07 y nunca en la pantalla del usuario. Empaquetas en jar ejecutable con su script de arranque y conoces las opciones para el arranque instantáneo: CDS, jpackage y GraalVM Native Image. Y pruebas la CLI en los tres niveles: la lógica del comando con Mockito, el parseo con parseArgs, y de extremo a extremo capturando la salida con setOut/setErr y comprobando el código devuelto.

Queda una limitación evidente, y no es técnica: Marta Ruiz no va a abrir un terminal para consultar si «Refactorización» está disponible. La CLI resuelve la automatización y a la gente técnica; no resuelve el acceso del resto de la empresa, ni la integración con otras aplicaciones, ni una futura app móvil.

La siguiente lección construye el segundo adaptador de entrada: el módulo bibliotech-web, una API REST completa con Spring Boot. Verás el ciclo petición-respuesta, el servidor embebido y el DispatcherServlet —y descubrirás que todo eso es exactamente lo que tú resolviste a mano con sockets en el módulo 9—, el diseño REST con sus verbos y sus códigos de estado, la validación, el manejo global de errores con Problem Details, la paginación, la documentación automática con OpenAPI y las pruebas de la capa web. Con los mismos casos de uso de siempre debajo.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados