Crear una CLI en Go para automatizar tareas locales

Tutorial para crear una herramienta de terminal en Go: leer ficheros, validar inputs, generar salida y compilar a un binario distribuible.

Cover for Crear una CLI en Go para automatizar tareas locales

Solía escribir scripts de Bash para todo. Procesar un CSV, renombrar ficheros, validar configuraciones, generar informes. Funcionaba, hasta que dejaba de funcionar. Un espacio mal escapado, un sed que se comporta distinto en macOS y Linux, una variable sin comillas que revienta con nombres de fichero raros. Depurar un script de Bash de 200 líneas es una experiencia que no le deseo a nadie.

Un día reescribí uno de esos scripts en Go. Tardé un poco más en escribirlo, pero el resultado fue un binario que compilé una vez, copié a tres máquinas distintas y funcionó en todas sin instalar nada. Sin Python, sin Node, sin dependencias de sistema, sin “works on my machine”. Solo un ejecutable.

Desde entonces, cada vez que un script de Bash supera las 50 líneas, lo reescribo en Go. Y en este artículo voy a mostrar cómo construir una CLI real paso a paso: una herramienta que lee ficheros CSV, valida datos, genera un informe y se compila a un binario que puedes distribuir a cualquier máquina.


Por qué Go para herramientas de terminal

Hay decenas de lenguajes para escribir CLIs. Python tiene argparse y click. Rust tiene clap. Node tiene commander. Pero Go tiene tres ventajas que lo hacen especialmente bueno para herramientas de terminal:

Binario estático sin dependencias. Cuando compilas un programa en Go, obtienes un único fichero ejecutable. No necesitas un runtime, no necesitas un intérprete, no necesitas que la máquina destino tenga Go instalado. Copias el binario y funciona. Para herramientas internas que distribuyes a un equipo, esto elimina toda la fricción de instalación.

Compilación cruzada trivial. Desde tu Mac puedes generar binarios para Linux y Windows con dos variables de entorno. Sin Docker, sin máquinas virtuales, sin CI especial. Si tu equipo usa una mezcla de sistemas operativos, esto es transformador.

Arranque instantáneo. Los programas Go arrancan en milisegundos. No hay JVM que calentar, no hay intérprete que inicializar. Para una herramienta de terminal que ejecutas cien veces al día, la diferencia se nota. Compáralo con una CLI en Python que importa pandas y tarda dos segundos en arrancar para procesar un fichero de diez líneas.

Además, la librería estándar de Go incluye todo lo necesario para una CLI: parseo de argumentos, lectura de ficheros, manipulación de texto, codificación JSON/CSV, y manejo robusto de errores. Para la mayoría de herramientas no necesitas ni una sola dependencia externa.

Si estás explorando Go y buscas ideas prácticas, en proyectos para aprender Go incluyo las CLIs como uno de los mejores puntos de entrada al lenguaje.


Preparar el proyecto

Empezamos creando un módulo Go. Si no tienes Go instalado, en cómo empezar con Go cubro la instalación y configuración del entorno.

mkdir csv-reporter && cd csv-reporter
go mod init github.com/tu-usuario/csv-reporter

Creamos el fichero main.go con la estructura mínima:

package main

import "fmt"

func main() {
    fmt.Println("csv-reporter v0.1.0")
}

Verificamos que compila y ejecuta:

go run main.go

Esto debería imprimir csv-reporter v0.1.0. Si funciona, tienes un proyecto Go funcional. El comando go run compila y ejecuta en un solo paso, ideal para desarrollo. Si quieres profundizar en qué hace cada subcomando de Go, en el comando go cubro run, build, test, fmt y el resto.


Parsear argumentos: flag vs os.Args

Go ofrece dos formas básicas de leer argumentos de la terminal: acceder directamente a os.Args o usar el paquete flag de la librería estándar.

os.Args: acceso directo

os.Args es un slice de strings con todos los argumentos que recibe el programa. El primer elemento es siempre el nombre del ejecutable:

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "uso: csv-reporter <fichero.csv>")
        os.Exit(1)
    }
    fmt.Println("Procesando:", os.Args[1])
}

Esto funciona para CLIs triviales con uno o dos argumentos posicionales. Pero en cuanto necesitas flags opcionales (--output, --format, --verbose), parsear os.Args a mano se convierte en un infierno de ifs y switches.

flag: el paquete estándar

El paquete flag resuelve este problema. Define flags con tipo, valor por defecto y descripción, y se encarga del parseo:

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    input := flag.String("input", "", "fichero CSV de entrada (obligatorio)")
    output := flag.String("output", "report.txt", "fichero de salida")
    verbose := flag.Bool("verbose", false, "mostrar información de depuración")

    flag.Parse()

    if *input == "" {
        fmt.Fprintln(os.Stderr, "error: el flag --input es obligatorio")
        flag.Usage()
        os.Exit(1)
    }

    if *verbose {
        fmt.Printf("Entrada: %s\n", *input)
        fmt.Printf("Salida: %s\n", *output)
    }

    fmt.Printf("Procesando %s -> %s\n", *input, *output)
}
go run main.go --input datos.csv --output informe.txt --verbose

Un detalle importante: flag genera automáticamente un mensaje de ayuda con -h o --help. No necesitas escribirlo tú. Cada flag aparece con su nombre, tipo, valor por defecto y descripción.

$ go run main.go --help
Usage of csv-reporter:
  -input string
        fichero CSV de entrada (obligatorio)
  -output string
        fichero de salida (default "report.txt")
  -verbose
        mostrar información de depuración

Los flags devuelven punteros (*string, *bool), por eso los usas con *input, *output, etc. Si prefieres evitar punteros, puedes usar las variantes StringVar, BoolVar, etc., que escriben directamente en una variable:

var input string
flag.StringVar(&input, "input", "", "fichero CSV de entrada")

Para la mayoría de CLIs, flag es suficiente. Es simple, está en la librería estándar y no añade dependencias. Solo cuando necesitas subcomandos estilo git commit o docker build merece la pena traer algo externo.


Construir una herramienta real: procesador de CSV

Vamos a construir algo útil. Nuestra CLI va a leer un fichero CSV con datos de ventas, validar las filas, calcular estadísticas y generar un informe. El CSV de entrada tiene este formato:

fecha,producto,cantidad,precio
2026-01-15,Widget A,10,29.99
2026-01-15,Widget B,5,49.99
2026-01-16,Widget A,8,29.99
2026-01-16,Widget C,3,99.99

Estructura del proyecto

Antes de meter todo en main.go, vamos a separar responsabilidades. No necesitas una arquitectura compleja para una CLI, pero sí que el código esté organizado de forma legible:

csv-reporter/
├── main.go           # Punto de entrada, parseo de flags
├── reader.go         # Lectura y parseo del CSV
├── validator.go      # Validación de filas
├── reporter.go       # Generación del informe
└── go.mod

En estructura de proyecto Go cubro patrones más elaborados, pero para una CLI esta estructura plana funciona bien.

Definir los tipos

Empezamos definiendo las estructuras de datos en reader.go:

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "os"
    "strconv"
    "strings"
    "time"
)

type Sale struct {
    Date     time.Time
    Product  string
    Quantity int
    Price    float64
    Line     int // Número de línea para mensajes de error
}

func ReadCSV(path string) ([]Sale, []string, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, nil, fmt.Errorf("abriendo fichero: %w", err)
    }
    defer file.Close()

    reader := csv.NewReader(file)
    reader.TrimLeadingSpace = true

    // Leer y descartar la cabecera
    header, err := reader.Read()
    if err != nil {
        return nil, nil, fmt.Errorf("leyendo cabecera CSV: %w", err)
    }
    if len(header) < 4 {
        return nil, nil, fmt.Errorf("cabecera inválida: se esperan 4 columnas, hay %d", len(header))
    }

    var sales []Sale
    var warnings []string
    lineNum := 1 // La cabecera es la línea 1

    for {
        lineNum++
        record, err := reader.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            warnings = append(warnings, fmt.Sprintf("línea %d: error de formato CSV: %v", lineNum, err))
            continue
        }

        sale, err := parseRecord(record, lineNum)
        if err != nil {
            warnings = append(warnings, fmt.Sprintf("línea %d: %v", lineNum, err))
            continue
        }
        sales = append(sales, sale)
    }

    return sales, warnings, nil
}

func parseRecord(record []string, line int) (Sale, error) {
    if len(record) < 4 {
        return Sale{}, fmt.Errorf("se esperan 4 campos, hay %d", len(record))
    }

    date, err := time.Parse("2006-01-02", strings.TrimSpace(record[0]))
    if err != nil {
        return Sale{}, fmt.Errorf("fecha inválida %q: %w", record[0], err)
    }

    product := strings.TrimSpace(record[1])
    if product == "" {
        return Sale{}, fmt.Errorf("el producto no puede estar vacío")
    }

    quantity, err := strconv.Atoi(strings.TrimSpace(record[2]))
    if err != nil {
        return Sale{}, fmt.Errorf("cantidad inválida %q: %w", record[2], err)
    }
    if quantity <= 0 {
        return Sale{}, fmt.Errorf("la cantidad debe ser positiva: %d", quantity)
    }

    price, err := strconv.ParseFloat(strings.TrimSpace(record[3]), 64)
    if err != nil {
        return Sale{}, fmt.Errorf("precio inválido %q: %w", record[3], err)
    }
    if price < 0 {
        return Sale{}, fmt.Errorf("el precio no puede ser negativo: %.2f", price)
    }

    return Sale{
        Date:     date,
        Product:  product,
        Quantity: quantity,
        Price:    price,
        Line:     line,
    }, nil
}

Hay varias decisiones importantes aquí. Primera: la función ReadCSV devuelve tanto los datos válidos como una lista de warnings. No falla al encontrar la primera fila mala. En una herramienta de terminal, es mucho más útil procesar todo lo que se puede y reportar los problemas al final. El usuario no quiere ejecutar la herramienta diez veces para descubrir diez errores uno por uno.

Segunda: cada error incluye el número de línea. Cuando un CSV tiene 10.000 filas y tres están mal, saber que el problema está en la línea 4.287 te ahorra media hora de búsqueda.

Tercera: usamos fmt.Errorf con %w para envolver errores. Si quieres repasar por qué esto importa, en errores en Go lo cubro en detalle.


Validación de datos

En validator.go añadimos reglas de negocio que van más allá del formato:

package main

import (
    "fmt"
    "time"
)

type ValidationResult struct {
    Valid    []Sale
    Invalid []string
}

func Validate(sales []Sale) ValidationResult {
    var result ValidationResult
    now := time.Now()

    for _, s := range sales {
        if s.Date.After(now) {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("línea %d: fecha futura %s", s.Line, s.Date.Format("2006-01-02")))
            continue
        }

        if s.Price > 10000 {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("línea %d: precio sospechosamente alto %.2f para %s",
                    s.Line, s.Price, s.Product))
            continue
        }

        if s.Quantity > 1000 {
            result.Invalid = append(result.Invalid,
                fmt.Sprintf("línea %d: cantidad sospechosamente alta %d para %s",
                    s.Line, s.Quantity, s.Product))
            continue
        }

        result.Valid = append(result.Valid, s)
    }

    return result
}

La separación entre parseo y validación no es casual. El lector se encarga de que los datos tengan el formato correcto. El validador se encarga de que los datos tengan sentido. Son dos cosas distintas y cambiarán por motivos distintos. Si mañana necesitas aceptar un formato de fecha diferente, tocas el lector. Si necesitas un umbral de precio diferente, tocas el validador.


Generar el informe

En reporter.go calculamos estadísticas y generamos la salida:

package main

import (
    "fmt"
    "io"
    "sort"
    "strings"
    "text/tabwriter"
)

type ProductStats struct {
    Product       string
    TotalQuantity int
    TotalRevenue  float64
    AvgPrice      float64
    NumSales      int
}

func GenerateReport(w io.Writer, sales []Sale) {
    if len(sales) == 0 {
        fmt.Fprintln(w, "No hay datos válidos para generar el informe.")
        return
    }

    // Agrupar por producto
    statsMap := make(map[string]*ProductStats)
    var totalRevenue float64
    var totalQuantity int

    for _, s := range sales {
        stats, ok := statsMap[s.Product]
        if !ok {
            stats = &ProductStats{Product: s.Product}
            statsMap[s.Product] = stats
        }
        revenue := float64(s.Quantity) * s.Price
        stats.TotalQuantity += s.Quantity
        stats.TotalRevenue += revenue
        stats.NumSales++
        totalRevenue += revenue
        totalQuantity += s.Quantity
    }

    // Calcular precio medio y ordenar
    var statsList []ProductStats
    for _, stats := range statsMap {
        stats.AvgPrice = stats.TotalRevenue / float64(stats.TotalQuantity)
        statsList = append(statsList, *stats)
    }
    sort.Slice(statsList, func(i, j int) bool {
        return statsList[i].TotalRevenue > statsList[j].TotalRevenue
    })

    // Escribir informe
    fmt.Fprintln(w, strings.Repeat("=", 60))
    fmt.Fprintln(w, "INFORME DE VENTAS")
    fmt.Fprintln(w, strings.Repeat("=", 60))
    fmt.Fprintf(w, "Registros procesados: %d\n", len(sales))
    fmt.Fprintf(w, "Productos únicos:     %d\n", len(statsList))
    fmt.Fprintf(w, "Cantidad total:       %d\n", totalQuantity)
    fmt.Fprintf(w, "Facturación total:    %.2f EUR\n\n", totalRevenue)

    // Tabla por producto
    tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
    fmt.Fprintln(tw, "PRODUCTO\tVENTAS\tCANTIDAD\tPRECIO MEDIO\tFACTURACIÓN")
    fmt.Fprintln(tw, "--------\t------\t--------\t------------\t-----------")
    for _, s := range statsList {
        fmt.Fprintf(tw, "%s\t%d\t%d\t%.2f EUR\t%.2f EUR\n",
            s.Product, s.NumSales, s.TotalQuantity, s.AvgPrice, s.TotalRevenue)
    }
    tw.Flush()
}

Fíjate en que GenerateReport recibe un io.Writer, no un nombre de fichero. Esta es una decisión de diseño fundamental en Go: escribir contra interfaces, no contra implementaciones concretas. Si le pasas os.Stdout, imprime en la terminal. Si le pasas un fichero, escribe al fichero. Si le pasas un bytes.Buffer, lo puedes testear sin tocar el sistema de ficheros. Es la misma función para los tres casos.

tabwriter es un paquete de la librería estándar que alinea columnas automáticamente usando tabuladores. Produce salida limpia en la terminal sin necesidad de librerías externas.


Conectar todo en main.go

Ahora unimos los módulos en main.go:

package main

import (
    "flag"
    "fmt"
    "os"
)

const version = "0.1.0"

func main() {
    input := flag.String("input", "", "fichero CSV de entrada (obligatorio)")
    output := flag.String("output", "", "fichero de salida (por defecto: stdout)")
    showVersion := flag.Bool("version", false, "mostrar versión")
    verbose := flag.Bool("verbose", false, "mostrar información de depuración")

    flag.Parse()

    if *showVersion {
        fmt.Printf("csv-reporter v%s\n", version)
        os.Exit(0)
    }

    if *input == "" {
        fmt.Fprintln(os.Stderr, "error: el flag --input es obligatorio")
        fmt.Fprintln(os.Stderr, "")
        fmt.Fprintln(os.Stderr, "Uso: csv-reporter --input <fichero.csv> [--output <fichero.txt>]")
        os.Exit(1)
    }

    // Leer CSV
    sales, warnings, err := ReadCSV(*input)
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }

    if *verbose {
        fmt.Fprintf(os.Stderr, "Filas leídas: %d\n", len(sales))
        fmt.Fprintf(os.Stderr, "Warnings: %d\n", len(warnings))
    }

    // Mostrar warnings
    for _, w := range warnings {
        fmt.Fprintf(os.Stderr, "warning: %s\n", w)
    }

    // Validar
    result := Validate(sales)

    if *verbose {
        fmt.Fprintf(os.Stderr, "Filas válidas: %d\n", len(result.Valid))
        fmt.Fprintf(os.Stderr, "Filas inválidas: %d\n", len(result.Invalid))
    }

    for _, inv := range result.Invalid {
        fmt.Fprintf(os.Stderr, "validación: %s\n", inv)
    }

    // Generar informe
    var writer *os.File
    if *output != "" {
        writer, err = os.Create(*output)
        if err != nil {
            fmt.Fprintf(os.Stderr, "error creando fichero de salida: %v\n", err)
            os.Exit(1)
        }
        defer writer.Close()
    } else {
        writer = os.Stdout
    }

    GenerateReport(writer, result.Valid)

    if *output != "" {
        fmt.Fprintf(os.Stderr, "Informe generado en %s\n", *output)
    }
}

Observa que todos los mensajes de estado (warnings, errores, información de depuración) van a os.Stderr, y solo el informe va a os.Stdout o al fichero de salida. Esto es una convención importante en herramientas de terminal: stdout es para datos, stderr es para mensajes. Si alguien hace pipe de tu herramienta (csv-reporter --input datos.csv | less), los warnings no contaminan la salida.

Puedes probarlo todo con:

go run . --input datos.csv --verbose
go run . --input datos.csv --output informe.txt

Añadir subcomandos con Cobra

El paquete flag es suficiente para CLIs simples con una sola función. Pero si tu herramienta crece y necesitas subcomandos (piensa git commit, docker build, kubectl get), necesitas algo más estructurado.

Cobra es la librería de facto para CLIs en Go. La usan Kubernetes, Hugo, GitHub CLI y decenas de proyectos relevantes.

go get github.com/spf13/cobra@latest

La idea central de Cobra es simple: cada subcomando es un cobra.Command con su propio conjunto de flags y su función Run:

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:     "csv-reporter",
    Short:   "Herramienta para generar informes a partir de ficheros CSV",
    Version: "0.1.0",
}

var reportCmd = &cobra.Command{
    Use:   "report",
    Short: "Generar un informe de ventas",
    RunE: func(cmd *cobra.Command, args []string) error {
        input, _ := cmd.Flags().GetString("input")
        output, _ := cmd.Flags().GetString("output")

        sales, warnings, err := ReadCSV(input)
        if err != nil {
            return err
        }

        for _, w := range warnings {
            fmt.Fprintf(os.Stderr, "warning: %s\n", w)
        }

        result := Validate(sales)
        for _, inv := range result.Invalid {
            fmt.Fprintf(os.Stderr, "validación: %s\n", inv)
        }

        writer := os.Stdout
        if output != "" {
            f, err := os.Create(output)
            if err != nil {
                return fmt.Errorf("creando fichero de salida: %w", err)
            }
            defer f.Close()
            writer = f
        }

        GenerateReport(writer, result.Valid)
        return nil
    },
}

var validateCmd = &cobra.Command{
    Use:   "validate",
    Short: "Validar un fichero CSV sin generar informe",
    RunE: func(cmd *cobra.Command, args []string) error {
        input, _ := cmd.Flags().GetString("input")

        sales, warnings, err := ReadCSV(input)
        if err != nil {
            return err
        }

        for _, w := range warnings {
            fmt.Fprintf(os.Stderr, "warning: %s\n", w)
        }

        result := Validate(sales)
        if len(result.Invalid) == 0 && len(warnings) == 0 {
            fmt.Println("Todas las filas son válidas.")
        } else {
            for _, inv := range result.Invalid {
                fmt.Println(inv)
            }
        }
        fmt.Printf("\nResumen: %d válidas, %d inválidas, %d warnings\n",
            len(result.Valid), len(result.Invalid), len(warnings))
        return nil
    },
}

func init() {
    reportCmd.Flags().StringP("input", "i", "", "fichero CSV de entrada")
    reportCmd.Flags().StringP("output", "o", "", "fichero de salida")
    reportCmd.MarkFlagRequired("input")

    validateCmd.Flags().StringP("input", "i", "", "fichero CSV de entrada")
    validateCmd.MarkFlagRequired("input")

    rootCmd.AddCommand(reportCmd)
    rootCmd.AddCommand(validateCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
csv-reporter report --input datos.csv --output informe.txt
csv-reporter validate --input datos.csv
csv-reporter --help

Cobra genera ayuda automáticamente, soporta autocompletado para Bash/Zsh/Fish, y maneja los códigos de salida. El precio es una dependencia externa. Para una herramienta interna de equipo, merece la pena. Para un script personal que solo usas tú, quédate con flag.

No voy a extenderme más con Cobra aquí. Lo importante es que sepas que existe y cuándo tiene sentido usarlo. Para la versión con flag que hemos construido en las secciones anteriores, no lo necesitas.


Leer desde stdin y desde ficheros

Una CLI bien diseñada debe poder leer tanto de un fichero como de stdin. Esto permite usar pipes:

cat datos.csv | csv-reporter --input -
curl -s https://ejemplo.com/datos.csv | csv-reporter --input -

La convención universal es usar - como nombre de fichero para indicar stdin. Implementarlo es trivial:

func OpenInput(path string) (io.ReadCloser, error) {
    if path == "-" {
        return os.Stdin, nil
    }
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("abriendo %s: %w", path, err)
    }
    return file, nil
}

Devolvemos io.ReadCloser porque tanto os.File como os.Stdin implementan esa interfaz. El código que llama a OpenInput no necesita saber si está leyendo de un fichero o de la entrada estándar.

Para leer línea a línea desde stdin (útil para herramientas que procesan streams):

func ProcessStdin() error {
    scanner := bufio.NewScanner(os.Stdin)
    lineNum := 0

    for scanner.Scan() {
        lineNum++
        line := scanner.Text()
        // Procesar cada línea
        fmt.Printf("[%d] %s\n", lineNum, line)
    }

    if err := scanner.Err(); err != nil {
        return fmt.Errorf("leyendo stdin: %w", err)
    }
    return nil
}

Un detalle: bufio.Scanner tiene un límite por defecto de 64KB por línea. Si procesas ficheros con líneas muy largas (JSON en una sola línea, por ejemplo), necesitas aumentarlo:

scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB por línea

Salida con color e indicadores de progreso

Una CLI no necesita ser bonita, pero un poco de color ayuda a distinguir errores de información normal. La forma más sencilla es usar códigos ANSI directamente:

const (
    colorReset  = "\033[0m"
    colorRed    = "\033[31m"
    colorGreen  = "\033[32m"
    colorYellow = "\033[33m"
    colorCyan   = "\033[36m"
)

func printError(msg string) {
    fmt.Fprintf(os.Stderr, "%serror:%s %s\n", colorRed, colorReset, msg)
}

func printWarning(msg string) {
    fmt.Fprintf(os.Stderr, "%swarning:%s %s\n", colorYellow, colorReset, msg)
}

func printSuccess(msg string) {
    fmt.Fprintf(os.Stderr, "%s%s%s\n", colorGreen, msg, colorReset)
}

func printInfo(msg string) {
    fmt.Fprintf(os.Stderr, "%s%s%s\n", colorCyan, msg, colorReset)
}

Esto funciona en la mayoría de terminales modernas (macOS Terminal, iTerm2, terminales de Linux, Windows Terminal). Pero hay un matiz importante: si la salida de tu herramienta se redirige a un fichero o a un pipe, los códigos ANSI contaminan la salida. La solución es detectar si stderr es una terminal:

import "golang.org/x/term"

func useColor() bool {
    return term.IsTerminal(int(os.Stderr.Fd()))
}

Para indicadores de progreso cuando procesas ficheros grandes, un spinner simple basta:

func showProgress(current, total int) {
    percent := float64(current) / float64(total) * 100
    fmt.Fprintf(os.Stderr, "\rprocesando: %d/%d (%.0f%%)", current, total, percent)
    if current == total {
        fmt.Fprintln(os.Stderr)
    }
}

El \r (retorno de carro) mueve el cursor al inicio de la línea sin avanzar a la siguiente, lo que sobreescribe el texto anterior. Simple y efectivo.


Manejo de errores en CLIs: códigos de salida y mensajes claros

El manejo de errores en una CLI tiene requisitos distintos a los de un servidor web o una librería. En una CLI, el usuario es un humano sentado delante de una terminal. Los mensajes de error deben ser comprensibles, y los códigos de salida deben ser correctos para que funcionen en scripts.

Códigos de salida

La convención estándar es:

  • 0: ejecución correcta
  • 1: error genérico
  • 2: uso incorrecto (argumentos inválidos)

Algunas herramientas definen códigos adicionales (por ejemplo, grep usa 1 para “no encontrado” y 2 para errores). Para la mayoría de CLIs, 0 y 1 son suficientes, más 2 para errores de argumentos.

const (
    exitOK       = 0
    exitError    = 1
    exitBadUsage = 2
)

func main() {
    if err := run(); err != nil {
        var usageErr *UsageError
        if errors.As(err, &usageErr) {
            fmt.Fprintf(os.Stderr, "error: %v\n", err)
            flag.Usage()
            os.Exit(exitBadUsage)
        }
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(exitError)
    }
}

type UsageError struct {
    Msg string
}

func (e *UsageError) Error() string {
    return e.Msg
}

Un patrón importante: delegar toda la lógica a una función run() que retorna error, y dejar que main() solo se encargue de imprimir el error y llamar a os.Exit. Esto tiene dos ventajas. Primera, puedes testear run() sin que el test llame a os.Exit (que mataría el proceso del test). Segunda, defer no se ejecuta después de os.Exit, así que cuantos menos os.Exit tengas repartidos por el código, menos riesgo de recursos sin cerrar.

Mensajes de error legibles

Un error como open config.yaml: no such file or directory es técnicamente correcto pero no ayuda al usuario. Mejor:

fmt.Fprintf(os.Stderr, "error: no se puede abrir %q: ¿existe el fichero?\n", path)

Algunas reglas para mensajes de error en CLIs:

  • Empieza con minúscula (convención de Go y de muchas herramientas Unix)
  • No termines con punto
  • Incluye el dato concreto que falló (nombre del fichero, número de línea, valor inválido)
  • Si es un error del usuario, sugiere la solución
// Malo
fmt.Fprintln(os.Stderr, "Error occurred")

// Bien
fmt.Fprintf(os.Stderr, "error: el fichero %q no existe\n", path)

// Mejor
fmt.Fprintf(os.Stderr, "error: el fichero %q no existe; comprueba la ruta o usa --input para especificar otro\n", path)

Compilación cruzada: generar binarios para Linux, macOS y Windows

Una de las razones por las que Go es ideal para CLIs es que puedes compilar para cualquier plataforma desde cualquier máquina. Solo necesitas dos variables de entorno: GOOS y GOARCH.

# Linux AMD64 (servidores, contenedores Docker, WSL)
GOOS=linux GOARCH=amd64 go build -o csv-reporter-linux-amd64 .

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o csv-reporter-darwin-arm64 .

# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o csv-reporter-darwin-amd64 .

# Windows
GOOS=windows GOARCH=amd64 go build -o csv-reporter-windows-amd64.exe .

Si quieres saber qué variables de entorno afectan a la compilación, en variables de entorno en Go cubro GOOS, GOARCH y el resto.

Para automatizar la compilación de todas las plataformas, un script simple:

#!/bin/bash
APP_NAME="csv-reporter"
VERSION="0.1.0"
OUTPUT_DIR="dist"

mkdir -p "$OUTPUT_DIR"

platforms=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

for platform in "${platforms[@]}"; do
    GOOS="${platform%/*}"
    GOARCH="${platform#*/}"
    output="${OUTPUT_DIR}/${APP_NAME}-${VERSION}-${GOOS}-${GOARCH}"
    if [ "$GOOS" = "windows" ]; then
        output="${output}.exe"
    fi
    echo "Compilando $GOOS/$GOARCH..."
    GOOS=$GOOS GOARCH=$GOARCH go build -o "$output" .
done

echo "Binarios generados en $OUTPUT_DIR/"
ls -lh "$OUTPUT_DIR/"

Incrustar información de versión en el binario

Es buena práctica que el binario sepa su propia versión. Go permite inyectar valores en tiempo de compilación con -ldflags:

// En main.go
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)
go build -ldflags "-X main.version=0.1.0 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o csv-reporter .

Ahora csv-reporter --version muestra la versión exacta, el commit y la fecha de compilación. Cuando alguien reporta un bug, sabes exactamente qué versión del código está ejecutando.


Distribución con GoReleaser

Si tu herramienta es algo más que un script personal y necesitas distribuirla (a compañeros de equipo, a un repositorio público, como parte de un pipeline), GoReleaser automatiza todo el proceso de compilación, empaquetado y publicación.

GoReleaser lee un fichero .goreleaser.yaml en la raíz de tu proyecto y genera binarios para múltiples plataformas, crea archivos .tar.gz y .zip, genera checksums y publica en GitHub Releases.

# .goreleaser.yaml
version: 2
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

Para generar un release local (sin publicar en GitHub):

goreleaser release --snapshot --clean

Para publicar en GitHub Releases cuando haces un tag:

git tag v0.1.0
git push origin v0.1.0
goreleaser release

Esto genera los binarios, los sube como assets del release, y crea un changelog automático basado en los commits entre el tag anterior y el actual. Para herramientas de equipo, esto convierte la distribución de “manda un Slack con el binario adjunto” a “descárgalo de la página de releases”.

Los flags -s -w en ldflags eliminan la tabla de símbolos y la información de debug del binario. El resultado es un ejecutable más pequeño (típicamente un 30% menos) sin afectar al funcionamiento. Para herramientas de terminal que distribuyes, merece la pena.


Código completo y resumen

Aquí tienes la versión completa del main.go con flag (sin Cobra), incorporando todo lo cubierto en el artículo:

package main

import (
    "errors"
    "flag"
    "fmt"
    "os"
)

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

const (
    exitOK       = 0
    exitError    = 1
    exitBadUsage = 2
)

type UsageError struct {
    Msg string
}

func (e *UsageError) Error() string {
    return e.Msg
}

func run() error {
    input := flag.String("input", "", "fichero CSV de entrada (obligatorio)")
    output := flag.String("output", "", "fichero de salida (por defecto: stdout)")
    showVersion := flag.Bool("version", false, "mostrar versión y salir")
    verbose := flag.Bool("verbose", false, "mostrar información de depuración")

    flag.Parse()

    if *showVersion {
        fmt.Printf("csv-reporter %s (commit: %s, fecha: %s)\n", version, commit, date)
        return nil
    }

    if *input == "" {
        return &UsageError{Msg: "el flag --input es obligatorio"}
    }

    // Leer CSV
    reader, err := OpenInput(*input)
    if err != nil {
        return err
    }
    defer reader.Close()

    sales, warnings, err := ReadCSVFromReader(reader)
    if err != nil {
        return fmt.Errorf("leyendo CSV: %w", err)
    }

    if *verbose {
        fmt.Fprintf(os.Stderr, "filas leídas: %d, warnings: %d\n", len(sales), len(warnings))
    }

    for _, w := range warnings {
        printWarning(w)
    }

    // Validar
    result := Validate(sales)

    if *verbose {
        fmt.Fprintf(os.Stderr, "válidas: %d, inválidas: %d\n",
            len(result.Valid), len(result.Invalid))
    }

    for _, inv := range result.Invalid {
        printWarning(inv)
    }

    // Generar informe
    var writer *os.File
    if *output != "" {
        writer, err = os.Create(*output)
        if err != nil {
            return fmt.Errorf("creando fichero de salida: %w", err)
        }
        defer writer.Close()
    } else {
        writer = os.Stdout
    }

    GenerateReport(writer, result.Valid)

    if *output != "" {
        printSuccess(fmt.Sprintf("informe generado en %s", *output))
    }

    return nil
}

func main() {
    if err := run(); err != nil {
        var usageErr *UsageError
        if errors.As(err, &usageErr) {
            printError(err.Error())
            fmt.Fprintln(os.Stderr)
            flag.Usage()
            os.Exit(exitBadUsage)
        }
        printError(err.Error())
        os.Exit(exitError)
    }
}

Qué hemos cubierto

  • Parseo de argumentos con flag (y una introducción a Cobra para subcomandos).
  • Lectura de CSV con la librería estándar, con manejo de errores fila por fila.
  • Validación separada del parseo, con reglas de negocio explícitas.
  • Generación de informes con tabwriter, escribiendo contra io.Writer para flexibilidad.
  • Lectura desde stdin y ficheros con la convención -.
  • Salida con color usando códigos ANSI y detección de terminal.
  • Códigos de salida correctos para integración con scripts.
  • Compilación cruzada para Linux, macOS y Windows.
  • Distribución con GoReleaser.

Próximos pasos

Lo que falta en esta CLI es un buen conjunto de tests. Go tiene un framework de testing integrado que no necesita librerías externas. Puedes testear cada componente (lector, validador, generador de informes) de forma aislada gracias a que usamos interfaces (io.Writer, io.ReadCloser) en lugar de ficheros concretos.

Go brilla para herramientas de terminal por la misma razón que brilla para servicios backend: te da un binario simple que funciona, sin sorpresas, sin dependencias ocultas, sin “instala esto primero”. Para un desarrollador que necesita automatizar tareas y distribuir herramientas a un equipo, eso vale más que cualquier framework.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados