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,ensuredeben 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.Argsy usarflag.FlagSetpor 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.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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
| Comando | Propósito | Idempotente |
|---|---|---|
apictl fetch | Descarga JSON desde un endpoint y lo guarda | Sí (si se sobrescribe de forma controlada) |
apictl print | Imprime el archivo guardado (o lo valida) | Sí |
apictl version | Muestra versión del binario | Sí |
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
--jsony--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/apictlSi además quieres incluir commit:
go build -ldflags "-X main.version=1.2.0+$(git rev-parse --short HEAD)" -o apictl ./cmd/apictlBuild 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/apictlReducir tamaño (opcional)
go build -ldflags "-s -w -X main.version=1.2.0" -o apictl ./cmd/apictlDistribució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/.rpmsi 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.jsonapictl 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.jsonapictl version