Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables

Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables

Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.

Por Omar Flores

Cuando alguien menciona Go, muchos piensan en servidores web de alto rendimiento, microservicios escalables, o sistemas distribuidos complejos. Y aunque Go definitivamente excele en esas áreas, hay un aspecto del lenguaje que es igualmente poderoso pero frecuentemente subestimado: su capacidad para crear herramientas de línea de comandos que revolucionan tu flujo de trabajo.

He visto desarrolladores pasar horas escribiendo scripts en Bash que apenas logran ser mantenibles, o Python scripts que requieren gestión de dependencias compleja y tiempos de ejecución lentos. Luego descubren Go y se dan cuenta de que pueden compilar un binario único, portable, sin dependencias, que funciona idénticamente en Windows, macOS y Linux, y que se ejecuta con la velocidad de un programa compilado a nivel nativo.

Pero crear una CLI profesional no es solo escribir código que funcione. Es entender cómo construir herramientas que sean escalables, mantenibles, con experiencia de usuario excepcional, que manejen errores correctamente, que tengan configuración flexible, logging robusto, y que se integren naturalmente en pipelines de automatización complejos.

Este artículo es una guía exhaustiva y paso a paso que te llevará desde tu primer “Hola Mundo” en CLI hasta crear una herramienta de producción compleja que automatiza workflows reales. No es teoría. Es construcción práctica, ejemplo tras ejemplo, explicación tras explicación, decisión tras decisión.


Por Qué Go Es Perfecto para CLI

Antes de empezar a construir, necesitas entender por qué Go es especialmente adecuado para herramientas de línea de comandos.

La Paradoja de Go

Go es un lenguaje que, en la superficie, parece simple. Sin generics (hasta Go 1.18), sin excepciones (usa errores explícitos), sin orientación a objetos en el sentido tradicional. Algunos desarrolladores lo ven como un paso atrás. Pero esta aparente simplicidad es exactamente lo que hace que Go sea perfecto para CLI.

Razón 1: Compilación a Binario Único

Cuando compiles un programa Go, obtienes un único archivo ejecutable. No necesitas Python, no necesitas Ruby, no necesitas una máquina virtual de Java. Simplemente: un binario. Esto significa distribución trivial, sin dependencias de sistema, sin quebraderos de cabeza en ambientes de producción.

# Compila tu herramienta Go
go build -o mi-herramienta

# Funciona en cualquier máquina con el mismo SO/arquitectura
./mi-herramienta --help

Razón 2: Rendimiento Nativo

Go se compila a código máquina. No es interpretado, no es JIT compilado. Es nativo. Una herramienta CLI que procesa 100,000 archivos en Python puede tardar minutos. La misma herramienta en Go: segundos. Esto no es exageración, son diferencias de 10-100x en operaciones intensivas de I/O y procesamiento.

Razón 3: Pensamiento Procedural Natural

Las herramientas de CLI son fundamentalmente procedurales. Haces esto, luego aquello, luego aquello otro. Go fue diseñado exactamente para este tipo de pensamiento. No luchas contra una arquitectura orientada a objetos innecesaria. Escribes lo que necesitas, de la manera más directa posible.

Razón 4: Funcionalidad de Concurrencia Integrada

Cuando necesites procesar 1000 archivos en paralelo, o consultar 100 APIs simultáneamente, Go te da goroutines: concurrencia ligera que es tan simple de usar como go miFunc(). En Bash es pesadilla. En Python requiere aprender async/await o threading complejo. En Go: trivial.

Razón 5: Ecosistema Maduro de Librerías para CLI

Go tiene librerías específicamente diseñadas para crear CLIs profesionales. Manejar flags/argumentos, mostrar progreso, colorear output, crear prompts interactivos, todo está disponible y bien documentado.


Fundamentos: Tu Primer Programa CLI

Setup Inicial

Primero, verifica tu versión de Go:

go version
# Debería mostrar go version go1.25.5 o similar

Si no tienes Go 1.25.5, descargalo desde https://golang.org/dl

Crea tu primer proyecto:

# En una carpeta vacía
mkdir mis-herramientas-cli
cd mis-herramientas-cli

# Inicializa el módulo Go
go mod init github.com/tuusuario/mis-herramientas-cli

Esto crea un go.mod que gestiona tus dependencias:

module github.com/tuusuario/mis-herramientas-cli

go 1.25

El Programa Más Simple

Crea un archivo main.go:

package main

import (
	"fmt"
)

func main() {
	fmt.Println("Hola, mundo CLI")
}

Ejecuta:

go run main.go
# Output: Hola, mundo CLI

O compila:

go build -o hola-cli

# En Linux/macOS:
./hola-cli

# En Windows:
hola-cli.exe

¿Por qué esto es importante? Ya tienes un binario ejecutable, sin dependencias, que puedes distribuir a cualquiera. Eso es la base.


Manejo de Argumentos y Flags: La Verdadera Potencia CLI

Un programa CLI sin argumentos es como un teléfono sin marcado: no va a ningún lado. Necesitas entender cómo recibir entrada del usuario.

La Biblioteca estándar: flag

Go incluye en su biblioteca estándar el paquete flag, que es suficiente para muchos casos:

package main

import (
	"flag"
	"fmt"
)

func main() {
	// Define tus flags
	nombre := flag.String("nombre", "Mundo", "Nombre a saludar")
	verbose := flag.Bool("verbose", false, "Mostrar información detallada")

	// Parsea los argumentos
	flag.Parse()

	fmt.Printf("Hola, %s\n", *nombre)

	if *verbose {
		fmt.Println("Modo verbose activado")
	}
}

Uso:

go run main.go
# Output: Hola, Mundo

go run main.go -nombre Omar
# Output: Hola, Omar

go run main.go -nombre Omar -verbose
# Output:
# Hola, Omar
# Modo verbose activado

Puntos importantes:

  • Los flags que defines se parsean automáticamente
  • El paquete flag genera --help automáticamente
  • Devuelven punteros (*nombre), debes desreferenciarlo

La Alternativa Profesional: pflag (RECOMENDADA)

Para aplicaciones más complejas, usa pflag, que es compatible con GNU-style flags (soporta --flag además de -flag):

go get github.com/spf13/pflag
package main

import (
	"fmt"
	"github.com/spf13/pflag"
)

func main() {
	// Define flags con pflag
	nombre := pflag.String("nombre", "Mundo", "Nombre a saludar")
	verbose := pflag.BoolP("verbose", "v", false, "Modo verbose")
	puerto := pflag.IntP("puerto", "p", 8080, "Puerto a usar")

	// Parsea
	pflag.Parse()

	fmt.Printf("Nombre: %s\n", *nombre)
	fmt.Printf("Verbose: %v\n", *verbose)
	fmt.Printf("Puerto: %d\n", *puerto)
}

Uso:

go run main.go --nombre Omar -v --puerto 3000
# Output:
# Nombre: Omar
# Verbose: true
# Puerto: 3000

Ventajas sobre flag:

  • Soporta forma larga (--nombre) y corta (-n)
  • Más legible para usuarios
  • Compatible con herramientas Unix estándar

Cobra: El Framework para CLIs Profesionales

Cuando tu aplicación crece y necesitas subcomandos (como git commit, docker run, kubectl apply), flag y pflag se quedan cortos. Aquí entra Cobra, el framework estándar de la industria para CLIs en Go.

Instalación y Setup

# Instala Cobra y su generador de código
go get -u github.com/spf13/cobra/cmd/cobra
go install github.com/spf13/cobra/cmd/cobra@latest

# Verifica la instalación
cobra --version

Generando tu Primera Aplicación Cobra

# Crea una aplicación nueva
cobra-cli init tareas

cd tareas

Esto crea una estructura profesional:

tareas/
├── main.go
├── cmd/
│   └── root.go
├── go.mod
├── go.sum
└── LICENSE

main.go:

package main

import "tareas/cmd"

func main() {
	cmd.Execute()
}

cmd/root.go:

package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

// rootCmd representa el comando base
var rootCmd = &cobra.Command{
	Use:   "tareas",
	Short: "Gestor de tareas desde CLI",
	Long: `tareas es una herramienta CLI para gestionar tus tareas diarias
desde la línea de comandos, perfecta para automatizar flujos de trabajo.`,
	Run: func(cmd *cobra.Command, args []string) {
		// Acción del comando principal
		cmd.Help()
	},
}

func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

func init() {
	// Aquí defines flags globales que aplican a todos los comandos
}

Compila y prueba:

go run main.go --help
# Automáticamente genera ayuda profesional

Agregando Subcomandos

cobra-cli add crear
cobra-cli add listar
cobra-cli add eliminar

Esto crea cmd/crear.go, cmd/listar.go, cmd/eliminar.go. Edita cmd/crear.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var crearCmd = &cobra.Command{
	Use:   "crear [nombre]",
	Short: "Crea una nueva tarea",
	Long: `Crea una nueva tarea en tu lista personal.

Ejemplo: tareas crear "Hacer la compra"`,
	Args: cobra.MinimumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		titulo := args[0]
		fmt.Printf("✓ Tarea creada: %s\n", titulo)
	},
}

func init() {
	rootCmd.AddCommand(crearCmd)

	// Flags específicos del comando crear
	crearCmd.Flags().StringP("prioridad", "p", "normal", "Prioridad: baja, normal, alta")
	crearCmd.Flags().StringP("fecha", "f", "", "Fecha límite (YYYY-MM-DD)")
}

Uso:

go run main.go crear "Escribir artículo" -p alta -f 2025-12-31
# Output: ✓ Tarea creada: Escribir artículo

Manejo de Archivos y Configuración

Cualquier herramienta CLI profesional necesita almacenar estado. Las tareas que creaste deben persistir. Aquí vamos a crear un sistema de almacenamiento basado en archivos JSON.

Estructura de Datos

Crea pkg/tarea/tarea.go:

package tarea

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

// Tarea representa una tarea individual
type Tarea struct {
	ID        string    `json:"id"`
	Titulo    string    `json:"titulo"`
	Descripcion string  `json:"descripcion,omitempty"`
	Prioridad string    `json:"prioridad"` // baja, normal, alta
	Estado    string    `json:"estado"`    // pendiente, completada
	Creada    time.Time `json:"creada"`
	Vencimiento time.Time `json:"vencimiento,omitempty"`
	Completada *time.Time `json:"completada,omitempty"`
}

// Store gestiona el almacenamiento de tareas
type Store struct {
	archivoPath string
}

// NewStore crea un nuevo almacén
func NewStore() (*Store, error) {
	// Determina dónde almacenar los datos
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("no se pudo obtener el directorio home: %w", err)
	}

	// Crea .tareas-cli/datos en el home del usuario
	dataDir := filepath.Join(home, ".tareas-cli")
	if err := os.MkdirAll(dataDir, 0755); err != nil {
		return nil, fmt.Errorf("error creando directorio de datos: %w", err)
	}

	archivoPath := filepath.Join(dataDir, "tareas.json")

	return &Store{
		archivoPath: archivoPath,
	}, nil
}

// Cargar lee todas las tareas del archivo
func (s *Store) Cargar() ([]Tarea, error) {
	// Si el archivo no existe, retorna lista vacía
	if _, err := os.Stat(s.archivoPath); os.IsNotExist(err) {
		return []Tarea{}, nil
	}

	datos, err := os.ReadFile(s.archivoPath)
	if err != nil {
		return nil, fmt.Errorf("error leyendo archivo: %w", err)
	}

	var tareas []Tarea
	if err := json.Unmarshal(datos, &tareas); err != nil {
		return nil, fmt.Errorf("error parseando JSON: %w", err)
	}

	return tareas, nil
}

// Guardar persiste todas las tareas al archivo
func (s *Store) Guardar(tareas []Tarea) error {
	datos, err := json.MarshalIndent(tareas, "", "  ")
	if err != nil {
		return fmt.Errorf("error serializando JSON: %w", err)
	}

	if err := os.WriteFile(s.archivoPath, datos, 0644); err != nil {
		return fmt.Errorf("error escribiendo archivo: %w", err)
	}

	return nil
}

// Agregar añade una nueva tarea
func (s *Store) Agregar(titulo, descripcion, prioridad, fechaVencimiento string) (string, error) {
	tareas, err := s.Cargar()
	if err != nil {
		return "", err
	}

	// Genera un ID único (simple)
	id := fmt.Sprintf("tarea_%d", time.Now().UnixNano())

	tarea := Tarea{
		ID:        id,
		Titulo:    titulo,
		Descripcion: descripcion,
		Prioridad: prioridad,
		Estado:    "pendiente",
		Creada:    time.Now(),
	}

	// Parsea fecha de vencimiento si se proporciona
	if fechaVencimiento != "" {
		venc, err := time.Parse("2006-01-02", fechaVencimiento)
		if err != nil {
			return "", fmt.Errorf("formato de fecha inválido, usa YYYY-MM-DD: %w", err)
		}
		tarea.Vencimiento = venc
	}

	tareas = append(tareas, tarea)

	if err := s.Guardar(tareas); err != nil {
		return "", err
	}

	return id, nil
}

// Listar retorna todas las tareas con filtros opcionales
func (s *Store) Listar(estado string) ([]Tarea, error) {
	tareas, err := s.Cargar()
	if err != nil {
		return nil, err
	}

	if estado == "" {
		return tareas, nil
	}

	var filtradas []Tarea
	for _, t := range tareas {
		if t.Estado == estado {
			filtradas = append(filtradas, t)
		}
	}

	return filtradas, nil
}

// Completar marca una tarea como completada
func (s *Store) Completar(id string) error {
	tareas, err := s.Cargar()
	if err != nil {
		return err
	}

	for i, t := range tareas {
		if t.ID == id {
			ahora := time.Now()
			tareas[i].Completada = &ahora
			tareas[i].Estado = "completada"
			return s.Guardar(tareas)
		}
	}

	return fmt.Errorf("tarea no encontrada: %s", id)
}

// Eliminar remueve una tarea
func (s *Store) Eliminar(id string) error {
	tareas, err := s.Cargar()
	if err != nil {
		return err
	}

	var nuevas []Tarea
	encontrada := false

	for _, t := range tareas {
		if t.ID != id {
			nuevas = append(nuevas, t)
		} else {
			encontrada = true
		}
	}

	if !encontrada {
		return fmt.Errorf("tarea no encontrada: %s", id)
	}

	return s.Guardar(nuevas)
}

Integración con Cobra

Actualiza cmd/crear.go:

package cmd

import (
	"fmt"
	"tareas/pkg/tarea"

	"github.com/spf13/cobra"
)

var crearCmd = &cobra.Command{
	Use:   "crear [titulo]",
	Short: "Crea una nueva tarea",
	Args:  cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		// Obtén los flags
		prioridad, _ := cmd.Flags().GetString("prioridad")
		fecha, _ := cmd.Flags().GetString("fecha")
		descripcion, _ := cmd.Flags().GetString("descripcion")

		// Crea el almacén
		store, err := tarea.NewStore()
		if err != nil {
			return fmt.Errorf("error inicializando almacén: %w", err)
		}

		// Agrega la tarea
		id, err := store.Agregar(args[0], descripcion, prioridad, fecha)
		if err != nil {
			return err
		}

		fmt.Printf("✓ Tarea creada con ID: %s\n", id)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(crearCmd)
	crearCmd.Flags().StringP("prioridad", "p", "normal", "Prioridad: baja, normal, alta")
	crearCmd.Flags().StringP("fecha", "f", "", "Fecha límite (YYYY-MM-DD)")
	crearCmd.Flags().StringP("descripcion", "d", "", "Descripción detallada")
}

Crea cmd/listar.go:

package cmd

import (
	"fmt"
	"tareas/pkg/tarea"

	"github.com/spf13/cobra"
)

var listarCmd = &cobra.Command{
	Use:   "listar",
	Short: "Lista todas tus tareas",
	RunE: func(cmd *cobra.Command, args []string) error {
		estado, _ := cmd.Flags().GetString("estado")

		store, err := tarea.NewStore()
		if err != nil {
			return err
		}

		tareas, err := store.Listar(estado)
		if err != nil {
			return err
		}

		if len(tareas) == 0 {
			fmt.Println("No hay tareas que mostrar")
			return nil
		}

		// Imprime en formato tabla
		fmt.Printf("\n%-8s | %-30s | %-8s | %-10s\n", "ID", "Título", "Prioridad", "Estado")
		fmt.Println(string(make([]byte, 70)))

		for _, t := range tareas {
			// Limita el título a 30 caracteres
			titulo := t.Titulo
			if len(titulo) > 30 {
				titulo = titulo[:27] + "..."
			}

			fmt.Printf("%-8s | %-30s | %-8s | %-10s\n",
				t.ID, titulo, t.Prioridad, t.Estado)
		}
		fmt.Println()

		return nil
	},
}

func init() {
	rootCmd.AddCommand(listarCmd)
	listarCmd.Flags().StringP("estado", "e", "", "Filtrar por estado (pendiente, completada)")
}

Crea cmd/completar.go:

package cmd

import (
	"fmt"
	"tareas/pkg/tarea"

	"github.com/spf13/cobra"
)

var completarCmd = &cobra.Command{
	Use:   "completar [id]",
	Short: "Marca una tarea como completada",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		store, err := tarea.NewStore()
		if err != nil {
			return err
		}

		if err := store.Completar(args[0]); err != nil {
			return err
		}

		fmt.Printf("✓ Tarea %s marcada como completada\n", args[0])
		return nil
	},
}

func init() {
	rootCmd.AddCommand(completarCmd)
}

Prueba tu aplicación:

go run main.go crear "Aprender Go"
go run main.go crear "Escribir código" -p alta
go run main.go listar
go run main.go completar tarea_1734800000000000000

Colorización y Salida Formateada: Experiencia de Usuario

Las herramientas CLI modernas no son aburridas. Agreguemos colores y formateo profesional con termui o lipgloss de Charm Inc.

Instalación

go get github.com/charmbracelet/lipgloss

Crear un Paquete de Formatos

Crea pkg/ui/ui.go:

package ui

import (
	"fmt"
	"github.com/charmbracelet/lipgloss"
)

// Colores y estilos
var (
	SuccessStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("10")). // Verde
		Bold(true)

	ErrorStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("9")). // Rojo
		Bold(true)

	WarningStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("11")). // Amarillo
		Bold(true)

	HeaderStyle = lipgloss.NewStyle().
		Foreground(lipgloss.Color("12")). // Azul
		Bold(true).
		Underline(true)

	PriorityAlta = lipgloss.NewStyle().
		Foreground(lipgloss.Color("9"))

	PriorityMedia = lipgloss.NewStyle().
		Foreground(lipgloss.Color("11"))

	PriorityBaja = lipgloss.NewStyle().
		Foreground(lipgloss.Color("10"))
)

// Success imprime un mensaje de éxito
func Success(msg string) {
	fmt.Println(SuccessStyle.Render("✓ " + msg))
}

// Error imprime un mensaje de error
func Error(msg string) {
	fmt.Println(ErrorStyle.Render("✗ " + msg))
}

// Warning imprime un mensaje de advertencia
func Warning(msg string) {
	fmt.Println(WarningStyle.Render("⚠ " + msg))
}

// Header imprime un encabezado
func Header(msg string) {
	fmt.Println(HeaderStyle.Render(msg))
	fmt.Println()
}

// PriorityColor retorna un color basado en prioridad
func PriorityColor(prioridad string) string {
	switch prioridad {
	case "alta":
		return PriorityAlta.Render(prioridad)
	case "media", "normal":
		return PriorityMedia.Render(prioridad)
	case "baja":
		return PriorityBaja.Render(prioridad)
	default:
		return prioridad
	}
}

Actualiza cmd/listar.go para usar estos estilos:

package cmd

import (
	"fmt"
	"tareas/pkg/tarea"
	"tareas/pkg/ui"

	"github.com/spf13/cobra"
)

var listarCmd = &cobra.Command{
	Use:   "listar",
	Short: "Lista todas tus tareas",
	RunE: func(cmd *cobra.Command, args []string) error {
		estado, _ := cmd.Flags().GetString("estado")

		store, err := tarea.NewStore()
		if err != nil {
			return err
		}

		tareas, err := store.Listar(estado)
		if err != nil {
			return err
		}

		if len(tareas) == 0 {
			ui.Warning("No hay tareas que mostrar")
			return nil
		}

		ui.Header("📋 Tus Tareas")

		// Imprime con colores
		for i, t := range tareas {
			titulo := t.Titulo
			if len(titulo) > 40 {
				titulo = titulo[:37] + "..."
			}

			// Símbolo de estado
			estadoSimbolo := "◯"
			if t.Estado == "completada" {
				estadoSimbolo = "✓"
			}

			fmt.Printf("%d. %s %s [%s] %s\n",
				i+1,
				estadoSimbolo,
				titulo,
				ui.PriorityColor(t.Prioridad),
				t.ID)
		}
		fmt.Println()

		return nil
	},
}

func init() {
	rootCmd.AddCommand(listarCmd)
	listarCmd.Flags().StringP("estado", "e", "", "Filtrar por estado (pendiente, completada)")
}

Flags Avanzados y Configuración

Para herramientas profesionales, necesitas configuración que persista. Vamos a agregar un archivo de configuración global.

Sistema de Configuración

Crea pkg/config/config.go:

package config

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
)

// Config representa la configuración global
type Config struct {
	Editor          string `json:"editor,omitempty"`
	DateFormat      string `json:"date_format"`
	Theme           string `json:"theme"`
	AutoSort        bool   `json:"auto_sort"`
	DefaultPriority string `json:"default_priority"`
}

// DefaultConfig retorna la configuración por defecto
func DefaultConfig() Config {
	return Config{
		DateFormat:      "2006-01-02",
		Theme:           "default",
		AutoSort:        true,
		DefaultPriority: "normal",
	}
}

// GetConfigPath retorna la ruta del archivo de configuración
func GetConfigPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}

	configDir := filepath.Join(home, ".tareas-cli")
	if err := os.MkdirAll(configDir, 0755); err != nil {
		return "", err
	}

	return filepath.Join(configDir, "config.json"), nil
}

// Load carga la configuración del archivo
func Load() (Config, error) {
	configPath, err := GetConfigPath()
	if err != nil {
		return DefaultConfig(), err
	}

	// Si el archivo no existe, usa defaults
	if _, err := os.Stat(configPath); os.IsNotExist(err) {
		return DefaultConfig(), nil
	}

	datos, err := os.ReadFile(configPath)
	if err != nil {
		return DefaultConfig(), err
	}

	var cfg Config
	if err := json.Unmarshal(datos, &cfg); err != nil {
		return DefaultConfig(), err
	}

	return cfg, nil
}

// Save persiste la configuración
func Save(cfg Config) error {
	configPath, err := GetConfigPath()
	if err != nil {
		return err
	}

	datos, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}

	return os.WriteFile(configPath, datos, 0644)
}

Comando para Configuración

Crea cmd/config.go:

package cmd

import (
	"fmt"
	"tareas/pkg/config"
	"tareas/pkg/ui"

	"github.com/spf13/cobra"
)

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Gestiona la configuración",
	RunE: func(cmd *cobra.Command, args []string) error {
		cfg, err := config.Load()
		if err != nil {
			return err
		}

		ui.Header("⚙️ Configuración Actual")
		fmt.Printf("Tema: %s\n", cfg.Theme)
		fmt.Printf("Formato de fecha: %s\n", cfg.DateFormat)
		fmt.Printf("Ordenar automáticamente: %v\n", cfg.AutoSort)
		fmt.Printf("Prioridad por defecto: %s\n", cfg.DefaultPriority)

		return nil
	},
}

var configSetCmd = &cobra.Command{
	Use:   "set [clave] [valor]",
	Short: "Establece una opción de configuración",
	Args:  cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
		clave := args[0]
		valor := args[1]

		cfg, err := config.Load()
		if err != nil {
			return err
		}

		switch clave {
		case "theme":
			cfg.Theme = valor
		case "date-format":
			cfg.DateFormat = valor
		case "default-priority":
			if valor != "baja" && valor != "normal" && valor != "alta" {
				ui.Error("Prioridad inválida. Usa: baja, normal, alta")
				return nil
			}
			cfg.DefaultPriority = valor
		default:
			ui.Error(fmt.Sprintf("Opción desconocida: %s", clave))
			return nil
		}

		if err := config.Save(cfg); err != nil {
			return err
		}

		ui.Success(fmt.Sprintf("Configuración actualizada: %s = %s", clave, valor))
		return nil
	},
}

func init() {
	rootCmd.AddCommand(configCmd)
	configCmd.AddCommand(configSetCmd)
}

Logging Profesional

Las herramientas de producción necesitan logging. Vamos a usar zap, que es el estándar de Go para logging estructurado.

Instalación

go get go.uber.org/zap

Setup de Logging

Crea pkg/logger/logger.go:

package logger

import (
	"fmt"
	"os"
	"path/filepath"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

var Logger *zap.Logger

// Init inicializa el logger
func Init(verbose bool) error {
	// Determina nivel de log
	level := zapcore.InfoLevel
	if verbose {
		level = zapcore.DebugLevel
	}

	// Configuración del logger
	config := zap.NewProductionConfig()
	config.Level = zap.NewAtomicLevelAt(level)

	// Si verbose, imprime en consola; si no, escribe en archivo
	if verbose {
		config.OutputPaths = []string{"stdout"}
	} else {
		// Escribe logs en ~/.tareas-cli/logs
		home, err := os.UserHomeDir()
		if err != nil {
			return err
		}

		logsDir := filepath.Join(home, ".tareas-cli", "logs")
		if err := os.MkdirAll(logsDir, 0755); err != nil {
			return err
		}

		logFile := filepath.Join(logsDir, "app.log")
		config.OutputPaths = []string{logFile}
	}

	logger, err := config.Build()
	if err != nil {
		return fmt.Errorf("error inicializando logger: %w", err)
	}

	Logger = logger
	return nil
}

// Close cierra el logger correctamente
func Close() error {
	if Logger != nil {
		return Logger.Sync()
	}
	return nil
}

Actualiza cmd/root.go para inicializar logging:

package cmd

import (
	"os"
	"tareas/pkg/logger"

	"github.com/spf13/cobra"
)

var verbose bool

var rootCmd = &cobra.Command{
	Use:   "tareas",
	Short: "Gestor de tareas desde CLI",
	Long: `tareas es una herramienta CLI para gestionar tus tareas`,
	PersistentPreRun: func(cmd *cobra.Command, args []string) {
		logger.Init(verbose)
	},
	PersistentPostRun: func(cmd *cobra.Command, args []string) {
		logger.Close()
	},
	Run: func(cmd *cobra.Command, args []string) {
		cmd.Help()
	},
}

func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

func init() {
	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Modo verbose")
}

Proyecto Complejo: Automatizador de Tareas del Sistema

Ahora vamos a crear algo más ambicioso: una herramienta que automatiza tareas del sistema. Funciona en Windows y Linux.

Estructura

automatizador/
├── main.go
├── cmd/
│   ├── root.go
│   ├── backup.go
│   ├── clean.go
│   └── schedule.go
├── pkg/
│   ├── backup/
│   ├── cleaner/
│   └── scheduler/
└── go.mod

Módulo de Backup

Crea pkg/backup/backup.go:

package backup

import (
	"archive/zip"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"time"
)

// BackupConfig configura un backup
type BackupConfig struct {
	SourcePath string   // Carpeta a respaldar
	DestPath   string   // Dónde guardar el backup
	Exclude    []string // Patrones a excluir (ej: .git, node_modules)
}

// Backup crea un archivo comprimido del directorio especificado
func Backup(cfg BackupConfig) (string, error) {
	// Valida que la carpeta source existe
	sourceInfo, err := os.Stat(cfg.SourcePath)
	if err != nil {
		return "", fmt.Errorf("error accediendo a source: %w", err)
	}
	if !sourceInfo.IsDir() {
		return "", fmt.Errorf("source no es un directorio: %s", cfg.SourcePath)
	}

	// Crea el nombre del archivo backup
	timestamp := time.Now().Format("2006-01-02_150405")
	baseName := filepath.Base(cfg.SourcePath)
	backupName := fmt.Sprintf("%s_%s.zip", baseName, timestamp)
	backupPath := filepath.Join(cfg.DestPath, backupName)

	// Crea el directorio destino si no existe
	if err := os.MkdirAll(cfg.DestPath, 0755); err != nil {
		return "", fmt.Errorf("error creando directorio destino: %w", err)
	}

	// Abre el archivo zip para escribir
	zipFile, err := os.Create(backupPath)
	if err != nil {
		return "", fmt.Errorf("error creando archivo zip: %w", err)
	}
	defer zipFile.Close()

	zipWriter := zip.NewWriter(zipFile)
	defer zipWriter.Close()

	// Camina por todos los archivos
	err = filepath.Walk(cfg.SourcePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		// Obtén la ruta relativa
		relPath, err := filepath.Rel(cfg.SourcePath, path)
		if err != nil {
			return err
		}

		// Chequea si está en la lista de exclusión
		if shouldExclude(relPath, cfg.Exclude) {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		// Si es un directorio, solo crea la entrada
		if info.IsDir() {
			return nil
		}

		// Abre el archivo para lectura
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		defer file.Close()

		// Crea una entrada en el zip
		header, err := zip.FileInfoHeader(info)
		if err != nil {
			return err
		}
		header.Name = relPath

		writer, err := zipWriter.CreateHeader(header)
		if err != nil {
			return err
		}

		// Copia el contenido del archivo
		if _, err := io.Copy(writer, file); err != nil {
			return err
		}

		return nil
	})

	if err != nil {
		os.Remove(backupPath)
		return "", fmt.Errorf("error durante backup: %w", err)
	}

	return backupPath, nil
}

// shouldExclude chequea si un path debe excluirse
func shouldExclude(path string, patterns []string) bool {
	for _, pattern := range patterns {
		if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
			return true
		}
		if matched, _ := filepath.Match(pattern, path); matched {
			return true
		}
	}
	return false
}

// GetSize retorna el tamaño del archivo backup en bytes
func GetSize(path string) (int64, error) {
	info, err := os.Stat(path)
	if err != nil {
		return 0, err
	}
	return info.Size(), nil
}

Comando de Backup

Crea cmd/backup.go:

package cmd

import (
	"fmt"
	"os"
	"path/filepath"
	"tareas/pkg/backup"
	"tareas/pkg/ui"

	"github.com/spf13/cobra"
)

var backupCmd = &cobra.Command{
	Use:   "backup [source] [dest]",
	Short: "Crea un backup comprimido de una carpeta",
	Args:  cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
		source := args[0]
		dest := args[1]

		// Convierte a rutas absolutas
		sourcePath, err := filepath.Abs(source)
		if err != nil {
			return fmt.Errorf("error resolviendo path source: %w", err)
		}

		destPath, err := filepath.Abs(dest)
		if err != nil {
			return fmt.Errorf("error resolviendo path destino: %w", err)
		}

		// Valida que source existe
		if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
			ui.Error(fmt.Sprintf("La carpeta no existe: %s", sourcePath))
			return nil
		}

		ui.Header("🔄 Iniciando backup...")

		// Configura el backup
		cfg := backup.BackupConfig{
			SourcePath: sourcePath,
			DestPath:   destPath,
			Exclude: []string{
				".git",
				"node_modules",
				".DS_Store",
				"*.tmp",
			},
		}

		// Ejecuta el backup
		backupPath, err := backup.Backup(cfg)
		if err != nil {
			ui.Error(fmt.Sprintf("Error durante backup: %v", err))
			return nil
		}

		// Obtén el tamaño
		size, _ := backup.GetSize(backupPath)
		sizeMB := float64(size) / 1024 / 1024

		ui.Success(fmt.Sprintf("Backup completado: %s (%.2f MB)",
			filepath.Base(backupPath), sizeMB))

		return nil
	},
}

func init() {
	rootCmd.AddCommand(backupCmd)
	backupCmd.Flags().StringSliceP("exclude", "e", []string{}, "Patrones a excluir")
}

Interactividad: Prompts y Confirmaciones

Usa promptui de Manifold para crear experiencias interactivas:

go get github.com/manifoldco/promptui

Módulo de Interacción

Crea pkg/interactive/interactive.go:

package interactive

import (
	"fmt"
	"github.com/manifoldco/promptui"
)

// Confirm pregunta al usuario por confirmación
func Confirm(prompt string) (bool, error) {
	p := promptui.Select{
		Label: prompt,
		Items: []string{"Sí", "No"},
	}

	_, result, err := p.Run()
	if err != nil {
		return false, err
	}

	return result == "Sí", nil
}

// Input solicita entrada de texto
func Input(label string) (string, error) {
	p := promptui.Prompt{
		Label: label,
	}

	return p.Run()
}

// Select permite seleccionar de una lista
func Select(label string, items []string) (string, error) {
	p := promptui.Select{
		Label: label,
		Items: items,
	}

	_, result, err := p.Run()
	return result, err
}

// MultiSelect permite seleccionar múltiples items
func MultiSelect(label string, items []string) ([]string, error) {
	p := promptui.Select{
		Label: label,
		Items: items,
		Size:  len(items),
	}

	var selected []string

	for {
		_, choice, err := p.Run()
		if err != nil {
			break
		}

		selected = append(selected, choice)
	}

	if len(selected) == 0 {
		return nil, fmt.Errorf("no seleccionaste nada")
	}

	return selected, nil
}

Uso en Comandos

Actualiza cmd/crear.go para usar prompts si no se proporciona título:

package cmd

import (
	"fmt"
	"tareas/pkg/interactive"
	"tareas/pkg/tarea"
	"tareas/pkg/ui"

	"github.com/spf13/cobra"
)

var crearCmd = &cobra.Command{
	Use:   "crear [titulo]",
	Short: "Crea una nueva tarea",
	RunE: func(cmd *cobra.Command, args []string) error {
		var titulo string

		// Si no se proporciona título, pide interactivamente
		if len(args) == 0 {
			var err error
			titulo, err = interactive.Input("Título de la tarea")
			if err != nil {
				return fmt.Errorf("cancelado")
			}
		} else {
			titulo = args[0]
		}

		// Obtén prioridad interactivamente
		prioridad, _ := cmd.Flags().GetString("prioridad")
		if prioridad == "normal" {
			p, err := interactive.Select("Prioridad", []string{"baja", "normal", "alta"})
			if err == nil {
				prioridad = p
			}
		}

		// Resto del código...
		store, err := tarea.NewStore()
		if err != nil {
			return err
		}

		id, err := store.Agregar(titulo, "", prioridad, "")
		if err != nil {
			return err
		}

		ui.Success(fmt.Sprintf("Tarea creada con ID: %s", id))
		return nil
	},
}

func init() {
	rootCmd.AddCommand(crearCmd)
	crearCmd.Flags().StringP("prioridad", "p", "normal", "Prioridad")
}

Procesamiento de Archivos en Masa: Concurrencia

Uno de los mayores poderes de Go es la concurrencia. Vamos a procesar miles de archivos en paralelo.

Procesador de Archivos

Crea pkg/fileprocessor/processor.go:

package fileprocessor

import (
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

// FileProcessor procesa archivos de manera concurrente
type FileProcessor struct {
	Workers  int // Número de goroutines concurrentes
	callback func(path string) error
}

// New crea un nuevo procesador
func New(workers int, callback func(path string) error) *FileProcessor {
	return &FileProcessor{
		Workers:  workers,
		callback: callback,
	}
}

// ProcessDirectory procesa todos los archivos en un directorio
func (fp *FileProcessor) ProcessDirectory(dirPath string, pattern string) error {
	// Recolecta todos los archivos
	var files []string
	err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			// Si hay patrón, filtra
			if pattern == "" || matchPattern(filepath.Base(path), pattern) {
				files = append(files, path)
			}
		}

		return nil
	})

	if err != nil {
		return err
	}

	// Procesa los archivos con concurrencia
	return fp.processFiles(files)
}

// processFiles procesa una lista de archivos concurrentemente
func (fp *FileProcessor) processFiles(files []string) error {
	// Crea canales
	jobsChan := make(chan string, fp.Workers)
	errorsChan := make(chan error, len(files))
	var wg sync.WaitGroup

	// Inicia workers
	for i := 0; i < fp.Workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for filePath := range jobsChan {
				if err := fp.callback(filePath); err != nil {
					errorsChan <- fmt.Errorf("error procesando %s: %w", filePath, err)
				}
			}
		}()
	}

	// Envía trabajos
	for _, file := range files {
		jobsChan <- file
	}
	close(jobsChan)

	// Espera a que terminen
	wg.Wait()
	close(errorsChan)

	// Recolecta errores
	var errs []error
	for err := range errorsChan {
		errs = append(errs, err)
	}

	if len(errs) > 0 {
		return fmt.Errorf("se encontraron %d errores durante procesamiento", len(errs))
	}

	return nil
}

// matchPattern hace match simple de patrón
func matchPattern(name, pattern string) bool {
	matched, _ := filepath.Match(pattern, name)
	return matched
}

Comando para Limpiar Archivos Temporales

Crea cmd/limpiar.go:

package cmd

import (
	"fmt"
	"os"
	"path/filepath"
	"tareas/pkg/fileprocessor"
	"tareas/pkg/ui"

	"github.com/spf13/cobra"
)

var limpiarCmd = &cobra.Command{
	Use:   "limpiar [directorio]",
	Short: "Limpia archivos temporales",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		dirPath := args[0]

		// Valida que existe
		if _, err := os.Stat(dirPath); os.IsNotExist(err) {
			ui.Error(fmt.Sprintf("Directorio no existe: %s", dirPath))
			return nil
		}

		ui.Header("🧹 Limpiando archivos temporales...")

		// Archivos a buscar
		patterns := []string{"*.tmp", "*.log", "*.cache", ".DS_Store"}
		deletedCount := 0
		deletedSize := int64(0)

		for _, pattern := range patterns {
			processor := fileprocessor.New(4, func(filePath string) error {
				info, err := os.Stat(filePath)
				if err != nil {
					return err
				}

				if err := os.Remove(filePath); err != nil {
					return err
				}

				deletedCount++
				deletedSize += info.Size()

				fmt.Printf("  Eliminado: %s\n", filepath.Base(filePath))
				return nil
			})

			processor.ProcessDirectory(dirPath, pattern)
		}

		sizeMB := float64(deletedSize) / 1024 / 1024
		ui.Success(fmt.Sprintf("Limpieza completada: %d archivos, %.2f MB liberados",
			deletedCount, sizeMB))

		return nil
	},
}

func init() {
	rootCmd.AddCommand(limpiarCmd)
}

Distribución y Construcción Multiplataforma

Go hace trivial compilar para múltiples plataformas:

Build Script

Crea un archivo build.sh:

#!/bin/bash

# Build para diferentes plataformas
APP_NAME="tareas"
VERSION="1.0.0"

# Linux
GOOS=linux GOARCH=amd64 go build -o dist/${APP_NAME}-linux-amd64 -ldflags="-X main.Version=${VERSION}"

# macOS
GOOS=darwin GOARCH=amd64 go build -o dist/${APP_NAME}-darwin-amd64 -ldflags="-X main.Version=${VERSION}"
GOOS=darwin GOARCH=arm64 go build -o dist/${APP_NAME}-darwin-arm64 -ldflags="-X main.Version=${VERSION}"

# Windows
GOOS=windows GOARCH=amd64 go build -o dist/${APP_NAME}-windows-amd64.exe -ldflags="-X main.Version=${VERSION}"

echo "✓ Compilación completada"
ls -lh dist/

Ejecuta:

chmod +x build.sh
./build.sh

Escalabilidad: De Herramienta a Plataforma

Cuando tu CLI crece, considera:

1. Plugin System

Go permite cargar plugins dinámicamente:

// Carga plugins de extensión
plugin, err := plugin.Open("./plugins/myplugin.so")
if err != nil {
	return err
}

sym, err := plugin.Lookup("Execute")
if err != nil {
	return err
}

execute := sym.(func() error)
execute()

2. Actualización Automática

go get -u github.com/inconshreveable/go-update

Permite que tu CLI se actualice a sí misma.

3. Documentación Generada

Cobra puede generar documentación automáticamente:

import "github.com/spf13/cobra/doc"

doc.GenMarkdownTree(rootCmd, "./docs")

Recomendaciones Finales para Producción

1. Error Handling Robusto

Siempre usa %w en fmt.Errorf para preservar la cadena de errores:

// ❌ Malo
if err != nil {
	return fmt.Errorf("error: %v", err)
}

// ✅ Bueno
if err != nil {
	return fmt.Errorf("procesando archivo: %w", err)
}

2. Testing

// main_test.go
package main

import (
	"testing"
)

func TestExecution(t *testing.T) {
	// Pruebas
}

Ejecuta con go test ./...

3. Versionado Semántico

Incluye versión en tu binario:

var Version = "1.0.0"

var versionCmd = &cobra.Command{
	Use:   "version",
	Short: "Muestra la versión",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Printf("Version: %s\n", Version)
	},
}

Compila con:

go build -ldflags="-X main.Version=1.0.0"

4. Documentación

// Asegúrate que cada paquete tiene documentación

// Package tarea proporciona funcionalidad para gestionar tareas.
package tarea

// Tarea representa una tarea individual en el sistema.
type Tarea struct {
	// ID es el identificador único
	ID string
}

Genera docs con go doc ./...


Conclusión

Go es una herramienta extraordinaria para crear CLIs que funcionen en cualquier lugar, que se ejecuten rápidamente, y que sean fáciles de mantener. Has aprendido desde lo básico hasta crear herramientas complejas y escalables.

Los puntos clave:

  • Cobra para estructura profesional
  • pflag para argumentos avanzados
  • Concurrencia para operaciones en masa
  • JSON para persistencia simple
  • lipgloss para UX
  • zap para logging profesional

El siguiente paso es crear tu propia herramienta. Automatiza algo que haces repetidamente, empaqu etala como binario, y distribúyela. Eso es el verdadero poder de Go.

Tags

#golang #cli #automation #devops #scripting #tools