Herramientas de línea de comandos en Go: automatización y ergonomía

Capítulo 9

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

Qué hace “buena” a una herramienta CLI en Go

Una herramienta de línea de comandos (CLI) es un programa pensado para ejecutarse desde terminal, integrarse con scripts y automatizar tareas. En Go, una CLI bien diseñada suele cumplir: ergonomía (mensajes claros, ayuda útil), automatización (salida estable para pipes, códigos de retorno correctos), idempotencia (repetir el comando no rompe nada) y configuración (flags y variables de entorno).

Convenciones prácticas

  • stdout para resultados normales (datos, confirmaciones).
  • stderr para errores, advertencias y logs de diagnóstico.
  • exit code: 0 éxito; distinto de 0 error. Usa códigos consistentes (por ejemplo 2 para uso incorrecto).
  • mensajes: una línea clara, accionable; si hay detalles, añádelos después.
  • idempotencia: comandos como sync, apply, ensure deben poder ejecutarse varias veces sin efectos inesperados.

Parsing de flags con la librería estándar

El paquete flag permite definir opciones como -v, -timeout o -config. Es suficiente para CLIs simples de un solo comando.

Ejemplo mínimo con validación y códigos de retorno

package main

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

func main() {
	var (
		name = flag.String("name", "", "Nombre a saludar (requerido)")
		json = flag.Bool("json", false, "Salida en JSON")
	)
	flag.Usage = func() {
		fmt.Fprintln(os.Stderr, "Uso: greet -name <nombre> [-json]")
		flag.PrintDefaults()
	}
	flag.Parse()

	if *name == "" {
		fmt.Fprintln(os.Stderr, "error: -name es requerido")
		flag.Usage()
		os.Exit(2)
	}

	if *json {
		fmt.Printf("{\"message\":\"Hola, %s\"}\n", *name)
		return
	}
	fmt.Printf("Hola, %s\n", *name)
}

Observa: el error va a stderr, se imprime ayuda y se sale con código 2 (uso incorrecto). La salida “útil” va a stdout.

Subcomandos: estructura recomendada

Para herramientas con varios comandos (por ejemplo tool sync, tool status), hay dos enfoques comunes:

  • Estándar: parsear os.Args y usar flag.FlagSet por subcomando.
  • Framework: librerías como Cobra o urfave/cli (útiles si necesitas autocompletado, docs, muchos subcomandos). Aquí construiremos con estándar para entender la mecánica.

Patrón con FlagSet por comando

switch os.Args[1] {
case "sync":
	fs := flag.NewFlagSet("sync", flag.ContinueOnError)
	// flags del subcomando
case "status":
	fs := flag.NewFlagSet("status", flag.ContinueOnError)
default:
	// ayuda
}

Ventajas: control total sobre ayuda, validación y salida. Además, puedes testear cada comando como una función.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Ayuda y mensajes: que el usuario no adivine

Checklist de ayuda útil

  • Una línea de uso por subcomando: tool sync [flags] <ruta>.
  • Descripción corta del propósito.
  • Flags con valores por defecto.
  • Ejemplos reales (copiar/pegar).

Errores accionables

Evita “invalid input”. Prefiere: error: falta el argumento <ruta> (ejemplo: tool sync ./data). Si un flag tiene formato, indícalo: --since espera RFC3339, etc.

Salida estándar vs error estándar y formatos

Una CLI suele tener dos tipos de salida:

  • Humana: frases, tablas simples, colores (opcional).
  • Máquina: JSON o líneas estables para pipes.

Buena práctica: un flag --json o --format para salida estructurada. Mantén el JSON en stdout y cualquier log/diagnóstico en stderr.

Configuración por variables de entorno

Las variables de entorno son ideales para credenciales y valores por defecto en automatización (CI/CD). Patrón habitual: flags ganan a env, y env gana a defaults.

Función utilitaria simple

func envOrDefault(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

Ejemplos de variables típicas: APP_TOKEN, APP_BASE_URL, APP_TIMEOUT.

Caso práctico: CLI “apictl” para consumir una API y guardar resultados

Construiremos una herramienta con subcomandos para consultar un endpoint y guardar la respuesta en un archivo. El objetivo es practicar: subcomandos, flags, validación, salida stdout/stderr, códigos de retorno, idempotencia y configuración por entorno.

Diseño de comandos

ComandoPropósitoIdempotente
apictl fetchDescarga JSON desde un endpoint y lo guardaSí (si se sobrescribe de forma controlada)
apictl printImprime el archivo guardado (o lo valida)
apictl versionMuestra versión del binario

Interfaz de uso

  • apictl fetch <resource> --out data.json [--base-url URL] [--token TOKEN] [--overwrite] [--json]
  • apictl print --in data.json [--json]
  • apictl version

Configuración por entorno: APICTL_BASE_URL y APICTL_TOKEN.

Paso 1: esqueleto con enrutado de subcomandos

package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

var version = "dev" // se inyecta en build

func main() {
	code := run(os.Args, os.Stdout, os.Stderr)
	os.Exit(code)
}

func run(args []string, out, errOut io.Writer) int {
	if len(args) < 2 {
		printRootUsage(errOut)
		return 2
	}

	switch args[1] {
	case "fetch":
		return cmdFetch(args[2:], out, errOut)
	case "print":
		return cmdPrint(args[2:], out, errOut)
	case "version":
		fmt.Fprintln(out, version)
		return 0
	case "-h", "--help", "help":
		printRootUsage(out)
		return 0
	default:
		fmt.Fprintf(errOut, "error: subcomando desconocido: %s\n", args[1])
		printRootUsage(errOut)
		return 2
	}
}

func printRootUsage(w io.Writer) {
	fmt.Fprintln(w, "Uso: apictl <comando> [args] [flags]")
	fmt.Fprintln(w, "Comandos: fetch, print, version")
	fmt.Fprintln(w, "Ejemplos:")
	fmt.Fprintln(w, "  apictl fetch users --out users.json")
	fmt.Fprintln(w, "  apictl print --in users.json")
}

func envOrDefault(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

var errUsage = errors.New("usage")

Nota: run devuelve un código de salida. Esto facilita tests (inyectas out/errOut).

Paso 2: implementar fetch con flags, env y validación

func cmdFetch(args []string, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
	fs.SetOutput(errOut)

	baseURL := fs.String("base-url", envOrDefault("APICTL_BASE_URL", "https://api.example.com"), "Base URL de la API (o env APICTL_BASE_URL)")
	token := fs.String("token", envOrDefault("APICTL_TOKEN", ""), "Token Bearer (o env APICTL_TOKEN)")
	outPath := fs.String("out", "", "Ruta de salida (requerido)")
	overwrite := fs.Bool("overwrite", false, "Sobrescribe si el archivo existe")
	jsonOut := fs.Bool("json", false, "Salida en JSON (para automatización)")
	timeout := fs.Duration("timeout", 10*time.Second, "Timeout HTTP")

	fs.Usage = func() {
		fmt.Fprintln(errOut, "Uso: apictl fetch <resource> --out <archivo> [--base-url URL] [--token TOKEN] [--overwrite] [--timeout 10s]")
		fs.PrintDefaults()
	}

	if err := fs.Parse(args); err != nil {
		return 2
	}

	pos := fs.Args()
	if len(pos) != 1 {
		fmt.Fprintln(errOut, "error: debes indicar <resource> (ej: apictl fetch users --out users.json)")
		fs.Usage()
		return 2
	}
	resource := pos[0]

	if *outPath == "" {
		fmt.Fprintln(errOut, "error: --out es requerido")
		fs.Usage()
		return 2
	}

	absOut, _ := filepath.Abs(*outPath)
	if fileExists(absOut) && !*overwrite {
		fmt.Fprintf(errOut, "error: el archivo ya existe: %s (usa --overwrite)\n", absOut)
		return 3
	}

	url := fmt.Sprintf("%s/%s", trimSlash(*baseURL), resource)
	client := &http.Client{Timeout: *timeout}

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		fmt.Fprintf(errOut, "error: creando request: %v\n", err)
		return 1
	}
	if *token != "" {
		req.Header.Set("Authorization", "Bearer "+*token)
	}

	resp, err := client.Do(req)
	if err != nil {
		fmt.Fprintf(errOut, "error: request fallida: %v\n", err)
		return 1
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		fmt.Fprintf(errOut, "error: API respondió %d %s\n", resp.StatusCode, resp.Status)
		return 4
	}

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Fprintf(errOut, "error: leyendo respuesta: %v\n", err)
		return 1
	}

	if err := os.WriteFile(absOut, data, 0o644); err != nil {
		fmt.Fprintf(errOut, "error: escribiendo archivo: %v\n", err)
		return 1
	}

	if *jsonOut {
		fmt.Fprintf(out, "{\"ok\":true,\"out\":%q,\"bytes\":%d}\n", absOut, len(data))
		return 0
	}
	fmt.Fprintf(out, "Guardado en %s (%d bytes)\n", absOut, len(data))
	return 0
}

func trimSlash(s string) string {
	for len(s) > 0 && s[len(s)-1] == '/' {
		s = s[:len(s)-1]
	}
	return s
}

Idempotencia: por defecto no sobrescribe; con --overwrite el efecto es explícito. Esto evita destruir datos por accidente y hace el comando seguro en automatizaciones.

Paso 3: implementar print para salida humana o JSON

func cmdPrint(args []string, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("print", flag.ContinueOnError)
	fs.SetOutput(errOut)

	inPath := fs.String("in", "", "Archivo a imprimir (requerido)")
	jsonOut := fs.Bool("json", false, "Salida en JSON")

	fs.Usage = func() {
		fmt.Fprintln(errOut, "Uso: apictl print --in <archivo> [--json]")
		fs.PrintDefaults()
	}

	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *inPath == "" {
		fmt.Fprintln(errOut, "error: --in es requerido")
		fs.Usage()
		return 2
	}

	b, err := os.ReadFile(*inPath)
	if err != nil {
		fmt.Fprintf(errOut, "error: no se pudo leer %s: %v\n", *inPath, err)
		return 1
	}

	if *jsonOut {
		fmt.Fprintf(out, "{\"file\":%q,\"bytes\":%d}\n", *inPath, len(b))
		return 0
	}
	out.Write(b)
	if len(b) == 0 || b[len(b)-1] != '\n' {
		fmt.Fprintln(out)
	}
	return 0
}

Este comando es naturalmente idempotente: leer e imprimir no modifica estado.

Validación de argumentos: reglas simples que evitan bugs

  • Cuenta exacta de posicionales por subcomando (como hicimos con len(pos) != 1).
  • Rangos y formatos: timeouts positivos, rutas válidas, URLs con esquema.
  • Conflictos: flags mutuamente excluyentes (por ejemplo --json y --quiet).
  • Errores de uso deben devolver código 2 y mostrar ayuda del subcomando.

Empaquetar, versionar y distribuir binarios

Inyectar versión en build

Definimos var version = "dev". En compilación, inyecta un valor con -ldflags:

go build -ldflags "-X main.version=1.2.0" -o apictl ./cmd/apictl

Si además quieres incluir commit:

go build -ldflags "-X main.version=1.2.0+$(git rev-parse --short HEAD)" -o apictl ./cmd/apictl

Build multiplataforma

GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=1.2.0" -o dist/apictl_linux_amd64 ./cmd/apictl
GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=1.2.0" -o dist/apictl_darwin_arm64 ./cmd/apictl
GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=1.2.0" -o dist/apictl_windows_amd64.exe ./cmd/apictl

Reducir tamaño (opcional)

go build -ldflags "-s -w -X main.version=1.2.0" -o apictl ./cmd/apictl

Distribución

  • GitHub Releases: sube binarios por plataforma y checksums.
  • Homebrew (macOS): fórmula que descarga el binario y verifica checksum.
  • Scoop/Chocolatey (Windows): manifiestos para instalación.
  • Paquetes: .deb/.rpm si tu entorno lo requiere.

Para automatizar releases, herramientas como GoReleaser son comunes: generan binarios, checksums y publican en releases. Aun si usas una herramienta externa, entender los comandos GOOS/GOARCH y -ldflags te permite depurar y personalizar el proceso.

Ergonomía extra: modos quiet, verbose y estabilidad para scripts

  • --quiet: no imprime mensajes humanos; ideal para cron/CI (pero conserva errores en stderr).
  • --verbose: imprime diagnósticos a stderr (URL final, tiempos, reintentos).
  • Salida estable: si ofreces --json, evita cambiar campos sin versionar; considera --format json|text.
  • Códigos de retorno: documenta qué significa cada uno (por ejemplo 3 archivo existe, 4 error HTTP).

Guía rápida de pruebas manuales

  • APICTL_BASE_URL=https://api.example.com APICTL_TOKEN=... apictl fetch users --out users.json
  • apictl fetch users --out users.json (debe fallar si existe, sin --overwrite)
  • apictl fetch users --out users.json --overwrite --json (stdout JSON, stderr vacío si todo va bien)
  • apictl print --in users.json
  • apictl version

Ahora responde el ejercicio sobre el contenido:

En una herramienta CLI en Go pensada para automatización, ¿qué comportamiento es el más adecuado cuando falta un flag requerido y se considera un error de uso?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Los errores de uso deben ser accionables, ir a stderr, mostrar la ayuda y devolver un código consistente (por ejemplo 2) para que los scripts detecten el fallo.

Siguiente capítulo

Testing en Go: unitarias, tablas de casos y calidad de código

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
75%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.