informe-diario.sh valida su entorno y avisa cuando algo se sale de lo normal, pero sigue haciendo siempre exactamente lo mismo: el día de hoy, todas las ciudades. Si dirección te pide el informe del lunes pasado, o solo el de Bilbao, tienes que editar el fichero. Eso es precisamente lo que separaba un alias de una herramienta, y en esta lección lo resolvemos. Al terminar, informe-diario.sh --fecha 2026-07-28 --ciudad Bilbao funcionará, habrá un --help que explique cómo usarlo, y el script sabrá pedir por teclado lo que no se le haya dicho.

Contenido

  1. Parámetros posicionales, $#, $@ y $*
  2. La diferencia crítica entre "$@" y "$*"
  3. shift: consumir argumentos
  4. Validar el número de argumentos y la función uso()
  5. Argumentos con nombre: while + case
  6. getopts para opciones cortas
  7. Valores por defecto y variables de entorno
  8. Entrada interactiva con read
  9. informe-diario.sh con --fecha y --ciudad

  1. Parámetros posicionales, $#, $@ y $*

Cuando invocas un script con argumentos, Bash los deja en variables numeradas llamadas parámetros posicionales. Al invocar informe-diario.sh Bilbao 2026-07-28 detallado, $0 contiene la ruta del script, $1 vale Bilbao, $2 vale 2026-07-28, $3 vale detallado y $4 en adelante están vacíos. Dentro del script los usarías como echo "Ciudad: $1 · Fecha: $2 · Modo: $3".

Hay una trampa a partir del décimo argumento: $10 no significa «argumento 10», sino «$1 seguido de un cero», porque Bash solo reconoce un dígito. A partir del décimo hay que usar llaves —${10}—, la misma regla de 03-02 aplicada a nombres numéricos. En la práctica, un script que recibe diez posicionales está mal diseñado; a partir de tres, lo correcto es pasar a argumentos con nombre (apartado 6).

$0 merece un comentario: contiene el nombre tal y como se invocó. Si ejecutas ./informe-diario.sh valdrá eso mismo, y si lo llamas por el PATH valdrá la ruta completa. Para mostrar solo el nombre en los mensajes de ayuda se usa nombre=$(basename "$0").

Tres variables especiales completan el panorama: $# es el número de argumentos recibidos (sin contar $0), $@ son todos los argumentos como lista y $* son todos como una sola cadena. Con echo "Recibidos $# argumentos: $@" dentro del script, la invocación informe-diario.sh Bilbao 2026-07-28 imprime Recibidos 2 argumentos: Bilbao 2026-07-28. $# es la clave para validar: (( $# == 0 )) detecta que no se pasó nada, (( $# > 2 )) que sobran argumentos.

  1. La diferencia crítica entre "$@" y "$*"

Sin comillas, $@ y $* se comportan igual. Entre comillas dobles son radicalmente distintos, y es una de las diferencias que más bugs sutiles causa en Bash:

Forma Se expande a
"$@" "$1" "$2" "$3" — cada argumento, una palabra separada
"$*" "$1 $2 $3" — todos unidos en una sola palabra (separador: el primer carácter de IFS)

La demostración clásica usa un fichero con espacios en el nombre. Invocando ./demo.sh "envios julio.csv" Bilbao:

for arg in "$@"; do echo "[$arg]"; done   # → [envios julio.csv]  y  [Bilbao]
for arg in "$*"; do echo "[$arg]"; done   # → [envios julio.csv Bilbao]

El primer bucle da dos vueltas y el segundo solo una. Con "$@" se conservan dos argumentos y el nombre con espacio sigue entero. Con "$*" todo se funde en una única cadena, y el receptor ya no puede saber dónde acababa un argumento y empezaba el siguiente.

Regla profesional: usa siempre "$@", con comillas. Es lo correcto para reenviar argumentos a otro comando: grep -c ERROR "$@" pasa cada fichero como un argumento independiente, mientras que grep -c ERROR "$*" le pasa todos los nombres pegados como si fueran uno solo, y falla.

"$*" solo tiene un uso legítimo: construir un mensaje legible, como echo "Argumentos recibidos: $*". Para todo lo demás, "$@".

  1. shift: consumir argumentos

shift descarta $1 y desplaza todos los demás una posición: $2 pasa a ser $1, $3 pasa a ser $2, y $# disminuye en uno. Partiendo de tres argumentos:

shift;   echo "$# args: $@"      # 2 args: 2026-07-28 detallado
shift 2; echo "$# args: $@"      # 0 args:

shift N desplaza N posiciones de una vez. Es el mecanismo que permite recorrer los argumentos consumiéndolos uno a uno, y es la base del bucle del apartado 6. Un detalle importante: shift falla (devuelve código 1) si no quedan argumentos que desplazar, lo que sirve como condición de parada.

  1. Validar el número de argumentos y la función uso()

Todo script que acepte argumentos debe saber explicarse; la convención universal es una función uso() que imprime la sintaxis:

uso() { cat <<FIN
Uso: $(basename "$0") [OPCIONES]
Genera el informe diario de actividad de Veloz Envíos.
  -f, --fecha AAAA-MM-DD   Fecha a analizar (por defecto: hoy)
  -c, --ciudad NOMBRE      Filtrar por ciudad (por defecto: todas)
  -v, --verboso            Mostrar detalle de errores
  -h, --help               Mostrar esta ayuda y salir
FIN
}

Esa construcción cat <<FIN ... FIN es un here-document: un bloque de texto literal que se envía a cat como si fuera un fichero. Es mucho más cómodo que veinte echo seguidos, y se estudia a fondo en 05-05; aquí basta con copiar el patrón. La sintaxis de funciones es de 04-02, pero la necesitas ya.

Con uso() definida, validar es inmediato: (( $# > 6 )) && { echo "ERROR: demasiados argumentos" >&2; uso >&2; exit 2; }. Fíjate en dos decisiones. La ayuda por error va a stderr (uso >&2), porque forma parte del diagnóstico; pero cuando el usuario pide --help explícitamente, la ayuda va a stdout y el script sale con código 0, porque eso no es un error. Y el código de salida para uso incorrecto es 2, siguiendo la convención de la lección 03-01.

  1. Argumentos con nombre: while + case

Los argumentos posicionales tienen un límite obvio: hay que recordar el orden. informe-diario.sh Bilbao 2026-07-28 obliga a saber que la ciudad va primero. Los argumentos con nombre eliminan ese problema y son el estándar de cualquier herramienta seria. El patrón universal en Bash combina un bucle while con case:

while [[ $# -gt 0 ]]; do
    case "$1" in
        -f|--fecha)   fecha="$2";   shift 2 ;;
        -c|--ciudad)  ciudad="$2";  shift 2 ;;
        -v|--verboso) verboso="si"; shift   ;;
        -h|--help)    uso; exit 0 ;;
        --)           shift; break ;;      # fin de las opciones
        -*) echo "ERROR: opción desconocida '$1'" >&2; uso >&2; exit 2 ;;
        *)  echo "ERROR: argumento inesperado '$1'" >&2; exit 2 ;;
    esac
done

Recorre el bloque despacio, porque lo escribirás muchas veces:

  • while [[ $# -gt 0 ]] sigue mientras queden argumentos; case "$1" mira el actual, y cada rama termina en ;;. La barra vertical de -f|--fecha) acepta la forma corta o la larga.
  • shift 2 en las opciones con valor consume la opción y su argumento; shift a secas basta para las banderas sin valor como -v.
  • -- es la convención universal para decir «a partir de aquí no hay más opciones», imprescindible cuando un valor podría empezar por guion. -*) captura cualquier opción no reconocida y falla explícitamente. Ignorar en silencio una opción mal escrita es de las peores decisiones posibles: --ciudda Bilbao produciría un informe de todas las ciudades sin decir nada. Y *) recoge los argumentos sueltos que no esperabas.

case se estudia formalmente en 04-05; aquí lo usamos como la herramienta práctica que es.

Un fallo frecuente: si el usuario escribe --fecha sin valor, $2 estará vacío y el script seguirá adelante con una fecha vacía. La defensa es comprobarlo en la propia rama, con [[ -n "${2:-}" ]] || { echo "ERROR: --fecha requiere un valor" >&2; exit 2; } antes de la asignación. Ese ${2:-} es expansión con valor por defecto (03-06).

  1. getopts para opciones cortas

Bash trae un builtin dedicado a analizar opciones cortas, getopts, de sintaxis muy compacta:

while getopts ":f:c:vh" opcion; do
    case "$opcion" in
        f) fecha="$OPTARG" ;;   c) ciudad="$OPTARG" ;;
        v) verboso="si"    ;;   h) uso; exit 0 ;;
        \?) echo "ERROR: opción desconocida -$OPTARG" >&2; exit 2 ;;
        :)  echo "ERROR: la opción -$OPTARG requiere un valor" >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))

Las piezas de esa cadena ":f:c:vh":

Elemento Significado
f: La opción -f requiere un valor (los dos puntos van detrás)
v La opción -v es una bandera sin valor
: inicial Modo silencioso: gestionas tú los errores con \? y :
$OPTARG / $OPTIND Valor de la opción actual / índice del siguiente argumento

shift $((OPTIND - 1)) al final descarta todas las opciones procesadas y deja en $1, $2… los argumentos que no eran opciones.

Ventajas de getopts: agrupa banderas (-vh equivale a -v -h), admite -f2026-07-28 pegado, y gestiona los errores por ti. Y su límite decisivo: no admite opciones largas; --fecha es imposible con getopts. La balanza queda así: getopts gana en código escrito y en agrupación de banderas, while + case gana en el único punto que de verdad decide.

Criterio de elección: si quieres opciones largas —y en una herramienta de operaciones las quieres, porque --ciudad se lee y -c hay que recordarlo— usa while + case. Es lo que haremos en informe-diario.sh.

  1. Valores por defecto y variables de entorno

Un buen script funciona sin argumentos, aplicando valores sensatos. La forma idiomática es asignarlos antes de procesar los argumentos, de modo que lo que no llegue por la línea de comandos conserve su valor inicial sin necesidad de condicionales adicionales. Y esos valores pueden venir a su vez de variables de entorno, retomando lo visto en 03-02: es la vía natural para configuración persistente que no quieres teclear cada vez.

fecha=$(date +%F)                   # por defecto, hoy
ciudad="${VELOZ_CIUDAD:-todas}"     # la variable de entorno si existe, si no "todas"

export VELOZ_CIUDAD=Bilbao
informe-diario.sh                   # ya filtra por Bilbao, sin argumentos
informe-diario.sh --ciudad Sevilla  # el argumento manda sobre el entorno

La convención de precedencia, de mayor a menor prioridad, es: argumento de línea de comandos > variable de entorno > fichero de configuración > valor por defecto. Como el bucle de argumentos se ejecuta después de estas asignaciones, esa jerarquía sale gratis con el orden correcto de las líneas. La expansión ${var:-valor} que ves aquí se explica a fondo en 03-06.

Este es el papel de ~/veloz-ops/etc/veloz-ops.conf (02-03): un fichero cargado con source que fija los valores habituales del equipo, sobre los que cada invocación puede imponer los suyos.

  1. Entrada interactiva con read

Cuando falta un dato y el script se ejecuta de forma interactiva, se puede preguntar al usuario con read -r -p "¿Qué ciudad quieres analizar? " ciudad. Sus opciones esenciales:

Opción Efecto
-p "texto" Muestra un prompt sin salto de línea
-r No interpreta la barra invertida como escape
-s Modo silencioso: no muestra lo tecleado (contraseñas)
-t N / -n N Espera N segundos y se rinde / lee N caracteres sin esperar al Enter
-a array Guarda las palabras en un array (04-03)

Usa -r siempre. Sin él, si el usuario escribe una ruta de Windows como C:\datos\envios.csv, Bash se comerá las barras invertidas y guardará C:datosenvios.csv. No hay ningún caso en operaciones donde quieras ese comportamiento.

Ahora bien: un script pensado para cron no puede preguntar nada, porque no hay nadie al otro lado y se quedaría colgado para siempre. Por eso hay que comprobar si la entrada es un terminal antes de preguntar:

read -rsp "Contraseña de la API: " clave; echo    # -s oculta; echo añade el salto
read -rt 10 -p "¿Continuar? [s/N] " resp || { echo "Sin respuesta"; exit 1; }

if [[ -t 0 ]]; then read -r -p "¿Qué ciudad? " ciudad
else ciudad="todas"        # sin terminal: por defecto, sin bloquearse
fi

[[ -t 0 ]] es verdadero si el descriptor 0 (stdin) está conectado a un terminal. Es la comprobación que evita que un script se cuelgue de madrugada esperando una respuesta que nunca llegará.

La misma idea permite leer de stdin cuando no hay argumentos, como hacen los filtros clásicos de Unix: if (( $# == 0 )) && [[ ! -t 0 ]]; then ciudades=$(cat); fi recoge por tubería lo que no llegó por la línea de comandos, de modo que echo Bilbao | informe-diario.sh funciona igual que pasar el argumento. La lectura de ficheros línea a línea con while read se ve en 04-01.

  1. informe-diario.sh con --fecha y --ciudad

Reunimos todo. La función uso() se abrevia aquí porque ya la viste completa en el apartado 4:

#!/usr/bin/env bash
# informe-diario.sh - Resumen diario de actividad de Veloz Envíos
# Uso     : informe-diario.sh [--fecha AAAA-MM-DD] [--ciudad NOMBRE] [-v] [-h]
# Códigos : 0 correcto | 2 uso incorrecto | 3 log ilegible | 4 CSV inválido
readonly RUTA_APP_LOG="/var/log/veloz/app.log"
readonly RUTA_CSV="/srv/veloz/datos/envios.csv"
readonly UMBRAL_ERRORES="${VELOZ_UMBRAL:-50}"
uso() { echo "Uso: $(basename "$0") [-f AAAA-MM-DD] [-c CIUDAD] [-v] [-h]"; }

# --- Valores por defecto y procesado de argumentos --------------------
fecha=$(date +%F)
ciudad="${VELOZ_CIUDAD:-todas}"
verboso="no"
while [[ $# -gt 0 ]]; do
    case "$1" in
        -f|--fecha)   fecha="$2";   shift 2 ;;
        -c|--ciudad)  ciudad="$2";  shift 2 ;;
        -v|--verboso) verboso="si"; shift   ;;
        -h|--help)    uso; exit 0 ;;
        *) echo "ERROR: opción desconocida '$1'" >&2; uso >&2; exit 2 ;;
    esac
done

# --- Validación -------------------------------------------------------
[[ "$fecha" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
    || { echo "ERROR: fecha '$fecha' no tiene formato AAAA-MM-DD" >&2; exit 2; }
[[ -r "$RUTA_APP_LOG" && -s "$RUTA_CSV" ]] || { echo "ERROR: sin datos" >&2; exit 3; }

# --- Informe ----------------------------------------------------------
echo "INFORME VELOZ ENVIOS  ·  fecha: $fecha  ·  ciudad: $ciudad"
total_errores=$(grep -c "^$fecha .*ERROR" "$RUTA_APP_LOG")
echo "Errores del día: $total_errores (umbral: $UMBRAL_ERRORES)"
[[ $verboso == "si" ]] && grep "^$fecha .*ERROR" "$RUTA_APP_LOG" | tail -5

lineas=$(grep ",$fecha," "$RUTA_CSV")
[[ $ciudad != "todas" ]] && lineas=$(echo "$lineas" | grep ",$ciudad,")
echo "-- Envíos por estado --"
echo "$lineas" | cut -d, -f5 | sort | uniq -c | sort -rn
exit 0
$ informe-diario.sh --ciudad Bilbao --fecha 2026-07-28 -v
INFORME VELOZ ENVIOS  ·  fecha: 2026-07-28  ·  ciudad: Bilbao
Errores del día: 12 (umbral: 50)
2026-07-28 18:22:41 [ERROR] fallo al geocodificar dirección de Bilbao
-- Envíos por estado --
     84 entregado
     11 incidencia

El =~ de la validación de fecha es el operador de expresiones regulares de [[ ]], que se estudia en 05-04; aquí comprueba que la fecha tenga cuatro dígitos, guion, dos dígitos, guion y dos dígitos. Es una validación de forma, no de calendario: 2026-13-45 la pasaría, y afinarla es tarea de más adelante. Fíjate también en cómo el umbral y la ciudad pueden venir del entorno con ${VELOZ_UMBRAL:-50} mientras que un --ciudad explícito gana siempre, porque el bucle se ejecuta después: la precedencia del apartado 7 sale gratis con el orden correcto de las líneas.

Errores Comunes y Consejos

  • Escribir $10 esperando el décimo argumento. Es $1 seguido de 0. Usa ${10}.
  • Usar $@ o $* sin comillas, o confundirlos. Sin comillas, los nombres con espacios se parten; con ellas, "$*" funde todo en una sola cadena. Siempre "$@".
  • Olvidar shift 2 en opciones con valor. El valor se procesará como si fuera otra opción, con resultados desconcertantes.
  • Ignorar opciones desconocidas. Una errata como --ciudda debe abortar con código 2, nunca pasar desapercibida.
  • Usar read sin -r. Las barras invertidas que teclee el usuario desaparecen.
  • Preguntar con read en un script de cron. Se colgará indefinidamente. Comprueba antes con [[ -t 0 ]].
  • Mandar --help a stderr o salir con código ≠ 0. Pedir ayuda no es un error: stdout y exit 0.

Ejercicios

Ejercicio 1 — Demostrar "$@" frente a "$*". Escribe un script contar-args.sh que imprima cuántos elementos ve al recorrer "$@" y cuántos al recorrer "$*", e invócalo con ./contar-args.sh "envios julio.csv" Bilbao 2026-07-28. Explica el resultado.

Ejercicio 2 — Un script con opciones completas. Crea ~/veloz-ops/bin/buscar-envios.sh que acepte --repartidor NOMBRE, --estado ESTADO, --limite N (por defecto 20) y --help, valide que el estado sea uno de entregado, en_reparto o incidencia, y muestre las líneas correspondientes de envios.csv. Debe salir con código 2 ante cualquier uso incorrecto.

Ejercicio 3 — Interactivo pero automatizable. Modifica el fragmento de la ciudad para que, si no se pasó --ciudad, pregunte por teclado solo si hay terminal, con un máximo de 15 segundos, y use todas si no hay terminal o se agota el tiempo.

Soluciones

Solución al Ejercicio 1

n=0; for a in "$@"; do (( n++ )); done; echo "Con \"\$@\": $n elementos"
m=0; for a in "$*"; do (( m++ )); done; echo "Con \"\$*\": $m elementos"

La salida es Con "$@": 3 elementos y Con "$*": 1 elemento. "$@" conserva la estructura: tres argumentos, y el primero mantiene su espacio interno intacto. "$*" los concatena en una única cadena usando el primer carácter de IFS (un espacio por defecto), y con ello se pierde irremediablemente la frontera entre argumentos. Por eso reenviar con "$*" a otro comando es un error: grep ERROR "$*" buscaría un fichero cuyo nombre completo fuera «envios julio.csv Bilbao 2026-07-28».

Solución al Ejercicio 2

#!/usr/bin/env bash
readonly RUTA_CSV="/srv/veloz/datos/envios.csv"
uso() { echo "Uso: $(basename "$0") [--repartidor N] [--estado E] [--limite N]"; }
repartidor=""; estado=""; limite=20

while [[ $# -gt 0 ]]; do            # mismo patrón del apartado 5
    case "$1" in
        --repartidor) repartidor="$2"; shift 2 ;;
        --estado)     estado="$2";     shift 2 ;;
        --limite)     limite="$2";     shift 2 ;;
        -h|--help)    uso; exit 0 ;;
        *) echo "ERROR: opción desconocida '$1'" >&2; uso >&2; exit 2 ;;
    esac
done
case "$estado" in
    entregado|en_reparto|incidencia|"") ;;      # válidos, o sin filtro
    *) echo "ERROR: estado '$estado' no válido" >&2; exit 2 ;;
esac
[[ "$limite" =~ ^[0-9]+$ ]] || { echo "ERROR: --limite no es un número" >&2; exit 2; }
resultado=$(tail -n +2 "$RUTA_CSV")
[[ -n $repartidor ]] && resultado=$(echo "$resultado" | grep ",$repartidor,")
[[ -n $estado ]]     && resultado=$(echo "$resultado" | grep ",$estado,")
echo "$resultado" | head -n "$limite"

Tres decisiones dignas de comentario. La validación del estado usa case con una rama que incluye "", porque una cadena vacía significa «sin filtro» y debe considerarse válida. El --limite se valida como número antes de pasárselo a head, que fallaría con un mensaje poco claro si recibiera texto. Y los filtros se aplican de forma acumulativa sobre una variable, lo que permite combinarlos o usarlos por separado sin duplicar código.

Solución al Ejercicio 3

if [[ -z $ciudad ]]; then
    [[ -t 0 ]] && { read -rt 15 -p "¿Qué ciudad? [todas] " ciudad || ciudad=""; }
    ciudad="${ciudad:-todas}"
fi

La lógica encaja tres protecciones en cuatro líneas. [[ -t 0 ]] impide que el script pregunte cuando lo lanza cron, evitando un bloqueo eterno. -t 15 limita la espera aunque haya terminal, por si el operador se marcha a por café. Y ${ciudad:-todas} cubre a la vez los tres caminos que dejan la variable vacía: sin terminal, tiempo agotado, o el usuario pulsando Enter sin escribir nada. Nota el || ciudad="" tras el read: cuando expira el tiempo, read devuelve un código distinto de cero y podría dejar basura en la variable, así que la vaciamos de forma explícita.

Conclusión

informe-diario.sh es ya una herramienta de línea de comandos como las del sistema. Sabes leer parámetros posicionales, incluyendo la trampa de ${10}; contar con $# y recorrer con "$@", entendiendo por qué "$*" destruye la separación entre argumentos; consumirlos con shift; documentar el uso con una función uso() y un here-document; procesar opciones largas con el patrón while + case, fallando ante lo desconocido en lugar de ignorarlo; conoces getopts, su OPTARG, su OPTIND y su límite con las opciones largas; asignas valores por defecto; preguntas con read -r sin colgar los trabajos de cron gracias a [[ -t 0 ]]; y aceptas configuración por variables de entorno con la precedencia correcta.

Queda un cabo suelto, y es el más importante del módulo. Mira las líneas que has escrito hoy: "$1", "$@", "$fecha". ¿Por qué unas van entre comillas y otras no? ¿Qué pasaría si alguien ejecutara informe-diario.sh --ciudad "San Sebastián", o si $ciudad llegara vacía en mitad de un [[ ]]? Has escrito ${2:-} y "${VELOZ_UMBRAL:-50}" sin una explicación completa.

Eso es la lección 03-06, que cierra el módulo por donde debía cerrarse: el orden exacto en que Bash expande una línea, qué hace cada tipo de comillas, por qué la división en palabras ocurre después de sustituir las variables, la familia completa de ${var:-def}, ${var:?} y ${var:+}, el papel de IFS, el desastre de for f in $(ls) y cuándo printf es mejor que echo. Es la lección que convierte los scripts que funcionan casi siempre en scripts que funcionan siempre.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados