tutoriales.com

Desarrollo de CLI Robustas en Go: Construyendo Herramientas de Línea de Comandos Interactivas

Este tutorial te guiará paso a paso en la creación de aplicaciones de línea de comandos (CLI) robustas y amigables con Go. Explorarás el uso de Cobra para la estructura de comandos, Viper para la gestión de configuración, y otras herramientas esenciales para una experiencia de usuario superior. Desarrollarás una CLI completa desde cero.

Intermedio25 min de lectura14 views
Reportar error

🚀 Introducción al Desarrollo de CLIs con Go

Las herramientas de línea de comandos (CLI) son esenciales para desarrolladores y administradores de sistemas, permitiendo automatizar tareas, interactuar con servicios y gestionar proyectos de manera eficiente. Go, con su rendimiento, concurrencia y facilidad de compilación en binarios estáticos, es un lenguaje ideal para construir CLIs potentes y portables.

En este tutorial, exploraremos cómo crear una CLI robusta y bien estructurada en Go, utilizando bibliotecas populares como Cobra para la gestión de comandos y Viper para la configuración. Aprenderás a definir comandos, subcomandos, banderas (flags), gestionar entradas de usuario y crear una experiencia interactiva.

¿Por qué Go para CLIs? 🤔

Go ofrece varias ventajas significativas para el desarrollo de herramientas de línea de comandos:

  • Rendimiento: Go es un lenguaje compilado que ofrece un rendimiento excelente, crucial para herramientas que necesitan ejecutarse rápidamente.
  • Binarios Estáticos: Compila tus aplicaciones en un único binario que no tiene dependencias externas, facilitando la distribución y el despliegue.
  • Concurrencia: Las goroutines y canales permiten manejar operaciones concurrentes de forma sencilla y eficiente, ideal para CLIs que interactúan con APIs o realizan tareas en paralelo.
  • Ecosistema Rico: Una gran cantidad de bibliotecas bien mantenidas para parsear argumentos, gestionar configuración, interactuar con sistemas de archivos, etc.
  • Portabilidad: El mismo código fuente puede compilarse fácilmente para diferentes sistemas operativos y arquitecturas.

🛠️ Herramientas Esenciales para una CLI en Go

Para construir una CLI de calidad, nos apoyaremos en algunas bibliotecas de Go que simplifican el proceso y añaden funcionalidad avanzada:

  • Cobra: Un generador y parser de comandos moderno y poderoso, popularizado por proyectos como kubectl y Hugo. Proporciona una estructura robusta para comandos, subcomandos, banderas y ayudas.
  • Viper: Una solución completa para la gestión de configuración en aplicaciones Go, que soporta JSON, TOML, YAML, HCL, env vars, y más.
  • pterm: Una biblioteca para salida de terminal hermosa, con tablas, barras de progreso, spinners y colores.
  • survey: Una biblioteca para crear encuestas interactivas con preguntas de entrada, selecciones múltiples, confirmaciones, etc.
💡 Consejo: Aunque este tutorial se centra en Cobra y Viper, existen otras alternativas como `urfave/cli` o `alecthomas/kong`. La elección depende del tamaño y la complejidad de tu proyecto.

🏗️ Estructura Básica de una CLI con Cobra

Cobra organiza tu aplicación CLI en una jerarquía de comandos. Tendrás un comando raíz, y opcionalmente, subcomandos anidados. Cada comando puede tener sus propias banderas y acciones.

Inicializando el Proyecto Go 📁

Primero, crearemos un nuevo módulo de Go y lo inicializaremos:

mkdir go-cli-tool
cd go-cli-tool
go mod init github.com/tu_usuario/go-cli-tool
go get github.com/spf13/cobra@latest github.com/spf13/viper@latest github.com/pterm/pterm@latest github.com/AlecAivazis/survey/v2@latest

Ahora, crearemos la estructura básica de Cobra. Puedes usar cobra init para generar un boilerplate, pero para entenderlo mejor, lo haremos manualmente.

Crea un archivo main.go:

package main

import (
	"go-cli-tool/cmd"
	"log"
)

func main() {
	if err := cmd.Execute(); err != nil {
		log.Fatalf("Error al ejecutar la CLI: %v", err)
	}
}

Y crea un directorio cmd con cmd/root.go:

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

var cfgFile string

// rootCmd representa el comando base cuando se llama a la aplicación sin subcomandos
var rootCmd = &cobra.Command{
	Use:   "go-cli-tool",
	Short: "Una herramienta CLI simple para gestionar tareas",
	Long: `go-cli-tool es una poderosa y flexible aplicación CLI
que te ayuda a gestionar tus tareas diarias de manera eficiente.

Completa con subcomandos para añadir, listar y completar tareas.`, 
	// Uncomment the following line if your bare application has an action
	// Run: func(cmd *cobra.Command, args []string) { },
}

// Execute agrega todos los comandos hijos al comando raíz y establece las banderas apropiadamente.
// Esto es llamado por main.main(). Solo necesita suceder una vez.
func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

func init() {
	cobra.OnInitialize(initConfig)

	// Banderas globales
	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "archivo de configuración (ej: $HOME/.go-cli-tool.yaml)")
	rootCmd.PersistentFlags().BoolP("toggle", "t", false, "Ayuda a la bandera de alternancia para demostración")

	// Banderas locales que solo se ejecutan cuando se llama a este comando directamente
	// rootCmd.Flags().BoolP("toggle", "t", false, "Ayuda a la bandera de alternancia para demostración")
}

// initConfig lee el archivo de configuración y las variables de entorno si están presentes.
func initConfig() {
	if cfgFile != "" {
		// Usa el archivo de configuración especificado por la bandera.
		vip.SetConfigFile(cfgFile)
	} else {
		// Busca en el directorio home.
		home, err := os.UserHomeDir()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}

		// Nombre del archivo de configuración (sin extensión).
		vip.AddConfigPath(home)
		vip.SetConfigName(".go-cli-tool")
		vip.SetConfigType("yaml") // o "json", "toml", etc.
	}

	vip.AutomaticEnv() // Lee variables de entorno que coincidan con los nombres de las claves

	if err := vip.ReadInConfig(); err == nil {
		fmt.Fprintln(os.Stderr, "Usando archivo de configuración:", vip.ConfigFileUsed())
	} else {
		// Manejar el error de configuración, por ejemplo, si no se encuentra el archivo
		// fmt.Fprintln(os.Stderr, "Advertencia: No se encontró archivo de configuración. Usando valores por defecto o variables de entorno.")
	}
}

var vip = viper.New() // Instancia de Viper para esta CLI

Ahora, puedes probar tu CLI ejecutando go run main.go. Verás el mensaje de ayuda de Cobra.

Añadiendo Comandos y Subcomandos ➕

Vamos a crear una CLI simple para gestionar tareas (todo). Necesitaremos comandos como add, list y complete.

Crea cmd/add.go:

package cmd

import (
	"fmt"
	"time"

	"github.com/spf13/cobra"
)

var taskDescription string
var dueDate string

var addCmd = &cobra.Command{
	Use:   "add [description]",
	Short: "Añade una nueva tarea",
	Long:  `Añade una nueva tarea a tu lista de pendientes.`, 
	Args:  cobra.ExactArgs(1), // Requiere exactamente un argumento para la descripción
	Run: func(cmd *cobra.Command, args []string) {
		description := args[0]
		fmt.Printf("Añadiendo tarea: '%s'\n", description)
		
		if dueDate != "" {
			// Lógica para parsear y usar la fecha de vencimiento
			fmt.Printf("Fecha de vencimiento: %s\n", dueDate)
		} else {
			fmt.Println("Sin fecha de vencimiento especificada.")
		}

		// Aquí iría la lógica para guardar la tarea en una base de datos o archivo
		fmt.Println("Tarea añadida con éxito! ✅")
	},
}

func init() {
	rootCmd.AddCommand(addCmd)

	// Banderas locales para el comando 'add'
	addCmd.Flags().StringVarP(&dueDate, "due", "d", "", "Fecha de vencimiento de la tarea (YYYY-MM-DD)")
}

Actualiza cmd/root.go para añadir el comando add al rootCmd (ya lo hicimos en add.go dentro de init(), así que solo asegúrate de que esté ahí si usaste el boilerplate de Cobra).

Ahora puedes ejecutar go run main.go add "Comprar leche" o go run main.go add "Enviar informe" -d "2023-12-31".

Gestión de Banderas (Flags) 🚩

Cobra simplifica la definición y el parseo de banderas. Hemos visto StringVarP para cadenas con una versión corta (-d) y larga (--due). Otros tipos de banderas incluyen BoolVarP, IntVarP, etc.

📌 Nota: Las banderas `PersistentFlags()` son heredadas por todos los subcomandos, mientras que `Flags()` son específicas del comando donde se definen.

⚙️ Gestión de Configuración con Viper

Viper es una herramienta poderosa para manejar la configuración de tu aplicación. Puede leer archivos de configuración, variables de entorno, remote key-value stores y más.

Hemos configurado Viper en cmd/root.go para buscar un archivo .go-cli-tool.yaml en el directorio home del usuario o un archivo especificado con --config.

Prioridad de Carga de Configuración 🎯

Viper tiene una prioridad de carga bien definida:

  1. Set values (establecidos programáticamente)
  2. Flags (banderas de línea de comandos)
  3. Variables de entorno
  4. Archivos de configuración
  5. Valores por defecto

Ejemplo de Configuración 📝

Imaginemos que nuestra herramienta necesita una API key. Podemos añadirla a la configuración. Modifica cmd/root.go para establecer un valor por defecto y luego leerlo.

// ... dentro de init()
	vip.SetDefault("api_key", "default_api_key_here")
	vip.SetDefault("database.path", "~/.go-cli-tool.db")
	// ...

Para acceder a estos valores, puedes usar vip.GetString("api_key") o vip.Get("database.path").

Ahora, crea un archivo ~/.go-cli-tool.yaml (o en tu directorio actual para pruebas) con el siguiente contenido:

api_key: "mi_clave_secreta_desde_yaml"
database:
  path: "/var/lib/go-cli-tool/tasks.db"

Si luego ejecutas go run main.go, verás que se usa el archivo de configuración. Si también defines una variable de entorno GO_CLI_TOOL_API_KEY="env_key", esta tendrá prioridad sobre el YAML.

Uso Práctico de Configuración en un Comando

Vamos a crear un comando config para ver los valores actuales:

Crea cmd/config.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
	"github.com/pterm/pterm"
)

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Muestra la configuración actual",
	Long:  `Muestra todos los valores de configuración cargados por Viper.`, 
	Run: func(cmd *cobra.Command, args []string) {
		data := [][]string{{"Clave", "Valor"}}
		for _, key := range vip.AllKeys() {
			data = append(data, []string{key, fmt.Sprintf("%v", vip.Get(key))})
		}

		pterm.DefaultTable.WithHas  Header().WithData(data).Render()
	},
}

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

Ahora, al ejecutar go run main.go config, verás una tabla con tus configuraciones.


🎨 Mejorando la Experiencia del Usuario con pterm y survey

Una CLI no solo debe ser funcional, sino también amigable y fácil de usar. Aquí es donde pterm y survey brillan.

Salida Atractiva con pterm

pterm permite añadir colores, tablas, barras de progreso y spinners para una mejor experiencia.

// Ejemplo de uso de pterm para mensajes coloridos
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
	"github.com/pterm/pterm"
)

var greetCmd = &cobra.Command{
	Use:   "greet [nombre]",
	Short: "Saluda a un usuario",
	Long:  `Este comando saluda a la persona especificada.`, 
	Args:  cobra.MinimumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		name := args[0]
		pterm.Info.Printf("Hola, %s! 👋\n", name)
		pterm.Success.Println("¡Operación exitosa!")
		pterm.Error.Println("¡Ha ocurrido un error inesperado!")
		pterm.Warning.Println("¡Atención, algo podría no ir bien!")

		pterm.DefaultSection.WithTopPadding(1).WithBottomPadding(1).WithTitle("Detalles Adicionales").Println("Aquí puedes poner información extra relevante.")
	},
}

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

Agrega greetCmd a rootCmd en su init() correspondiente (similar a addCmd). Luego, ejecuta go run main.go greet "Mundo".

CLI sin pterm $ echo "Mensaje simple en blanco y negro" Añadir pterm 🎨 Colores ✨ Iconos 📊 Tablas ⏳ Spinners CLI con pterm ✔ ¡Interfaz visualmente rica!

Interactividad con survey 🗣️

survey te permite hacer preguntas interactivas al usuario, ideal para cuando necesitas entrada dinámica.

Vamos a modificar el comando add para que, si no se proporciona una descripción, pregunte interactivamente.

package cmd

import (
	"fmt"
	"time"

	"github.com/spf13/cobra"
	"github.com/AlecAivazis/survey/v2"
	"github.com/pterm/pterm"
)

// ... (variables existentes para addCmd)

var addCmd = &cobra.Command{
	Use:   "add [description]",
	Short: "Añade una nueva tarea",
	Long:  `Añade una nueva tarea a tu lista de pendientes.`, 
	// Args:  cobra.ExactArgs(1), // Quitamos esta línea para permitir entrada interactiva
	Run: func(cmd *cobra.Command, args []string) {
		description := ""
		if len(args) > 0 {
			description = args[0]
		} else {
			// Si no se proporcionó descripción, preguntar interactivamente
			prompt := &survey.Input{
				Message: "¿Cuál es la descripción de la tarea?",
				Help:    "Ej: Comprar víveres, enviar email, etc.",
			}
			err := survey.AskOne(prompt, &description)
			if err != nil {
				pterm.Error.Printf("Error al obtener la descripción: %v\n", err)
				return
			}
			if description == "" {
				pterm.Warning.Println("La descripción de la tarea no puede estar vacía. Abortando.")
				return
			}
		}

		pterm.Info.Printf("Añadiendo tarea: '%s'\n", description)
		
		if dueDate != "" {
			// Lógica para parsear y usar la fecha de vencimiento
			pterm.DefaultSection.WithTitle("Detalles de la Tarea").Println(
				pterm.Sprintf("Descripción: %s\n", description) +
				pterm.Sprintf("Fecha de Vencimiento: %s", dueDate),
			)
		} else {
			pterm.Println("Sin fecha de vencimiento especificada.")
		}

		// Aquí iría la lógica para guardar la tarea en una base de datos o archivo
		pterm.Success.Println("Tarea añadida con éxito! ✅")
	},
}

func init() {
	rootCmd.AddCommand(addCmd)

	addCmd.Flags().StringVarP(&dueDate, "due", "d", "", "Fecha de vencimiento de la tarea (YYYY-MM-DD)")
}

Ahora, ejecuta go run main.go add. Te pedirá la descripción interactivamente.

Ejemplo de otros tipos de preguntas con `survey`
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
	"github.com/AlecAivazis/survey/v2"
	"github.com/pterm/pterm"
)

var surveyCmd = &cobra.Command{
	Use:   "survey",
	Short: "Demuestra preguntas interactivas con survey",
	Long:  `Este comando demuestra varios tipos de preguntas interactivas usando la biblioteca survey.`, 
	Run: func(cmd *cobra.Command, args []string) {
		var name string
		promptName := &survey.Input{
			Message: "¿Cuál es tu nombre?",
			Default: "Usuario",
		}
		survey.AskOne(promptName, &name)
		pterm.Info.Printf("Hola, %s!\n", name)

		var color string
		promptColor := &survey.Select{
			Message: "Elige tu color favorito:",
			Options: []string{"Rojo", "Verde", "Azul", "Amarillo"},
			Default: "Azul",
		}
		survey.AskOne(promptColor, &color)
		pterm.Info.Printf("Tu color favorito es: %s\n", color)

		var agree bool
		promptAgree := &survey.Confirm{
			Message: "¿Estás de acuerdo con los términos y condiciones?",
			Default: true,
		}
		survey.AskOne(promptAgree, &agree)
		pterm.Info.Printf("Acuerdo: %t\n", agree)

		var toppings []string
		promptToppings := &survey.MultiSelect{
			Message: "Elige tus toppings de pizza favoritos:",
			Options: []string{"Pepperoni", "Champiñones", "Cebolla", "Pimientos", "Extra Queso"},
		}
		survey.AskOne(promptToppings, &toppings)
		pterm.Info.Printf("Tus toppings elegidos: %v\n", toppings)
	},
}

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

📂 Persistencia de Datos para la CLI

Una CLI útil a menudo necesita almacenar datos. Aunque Viper es para configuración, para datos transaccionales, podemos usar una base de datos o archivos.

Usando un Archivo JSON Simple 📄

Para una CLI pequeña, un archivo JSON es una opción sencilla. Vamos a crear un comando list y complete para nuestra herramienta todo.

Primero, definamos una estructura para nuestras tareas y funciones auxiliares para leer/escribir.

Crea un nuevo paquete internal/task y dentro un archivo task.go:

package task

import (
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/spf13/viper"
)

// Task representa una única tarea en la lista de pendientes.
type Task struct {
	ID        int       `json:"id"`
	Description string    `json:"description"`
	Completed bool      `json:"completed"`
	CreatedAt time.Time `json:"created_at"`
	DueDate   *time.Time `json:"due_date,omitempty"`
}

// taskFile representa el archivo JSON que almacena las tareas.
type taskFile struct {
	Tasks []Task `json:"tasks"`
	LastID int  `json:"last_id"`
}

var ( 
	mu sync.Mutex // Para sincronizar el acceso al archivo de tareas
	currentTasks []Task
	nextID int
)

// GetTasksFilePath devuelve la ruta del archivo de tareas.
func GetTasksFilePath() (string, error) {
	configPath := viper.GetString("data.path") // Usamos Viper para obtener la ruta del archivo de datos
	if configPath == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("no se pudo obtener el directorio home del usuario: %w", err)
		}
		configPath = filepath.Join(home, ".go-cli-tool", "tasks.json")
	} else {
		// Asegúrate de que la ruta sea absoluta y resuelve ~ si es necesario
		if configPath[0] == '~' {
			home, err := os.UserHomeDir()
			if err != nil {
				return "", fmt.Errorf("no se pudo obtener el directorio home del usuario: %w", err)
			}
			configPath = filepath.Join(home, configPath[1:])
		}
		// Asegúrate de que el directorio existe
		dir := filepath.Dir(configPath)
		if _, err := os.Stat(dir); os.IsNotExist(err) {
			err := os.MkdirAll(dir, 0755)
			if err != nil {
				return "", fmt.Errorf("no se pudo crear el directorio de datos '%s': %w", dir, err)
			}
		}
	}
	return configPath, nil
}

// LoadTasks carga las tareas desde el archivo JSON.
func LoadTasks() ([]Task, error) {
	mu.Lock()
	defer mu.Unlock()

	filePath, err := GetTasksFilePath()
	if err != nil {
		return nil, err
	}

	data, err := os.ReadFile(filePath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			// Archivo no existe, devolver lista vacía
			currentTasks = []Task{}
			nextID = 1
			return currentTasks, nil
		}
		return nil, fmt.Errorf("no se pudo leer el archivo de tareas: %w", err)
	}

	var tf taskFile
	if err := json.Unmarshal(data, &tf); err != nil {
		return nil, fmt.Errorf("no se pudo deserializar el archivo de tareas: %w", err)
	}

	currentTasks = tf.Tasks
	nextID = tf.LastID + 1
	return currentTasks, nil
}

// SaveTasks guarda las tareas en el archivo JSON.
func SaveTasks(tasks []Task) error {
	mu.Lock()
	defer mu.Unlock()

	filePath, err := GetTasksFilePath()
	if err != nil {
		return err
	}

	tf := taskFile{
		Tasks: tasks,
		LastID: nextID - 1, // El último ID usado
	}

	data, err := json.MarshalIndent(tf, "", "  ")
	if err != nil {
		return fmt.Errorf("no se pudo serializar las tareas: %w", err)
	}

	err = os.WriteFile(filePath, data, 0644)
	if err != nil {
		return fmt.Errorf("no se pudo escribir el archivo de tareas: %w", err)
	}
	return nil
}

// AddTask añade una nueva tarea a la lista.
func AddTask(description string, dueDate *time.Time) (Task, error) {
	mu.Lock()
	defer mu.Unlock()

	t, err := LoadTasks()
	if err != nil {
		return Task{}, err
	}

	newTask := Task{
		ID:          nextID,
		Description: description,
		Completed:   false,
		CreatedAt:   time.Now(),
		DueDate:     dueDate,
	}
	currentTasks = append(t, newTask)
	nextID++

	if err := SaveTasks(currentTasks); err != nil {
		return Task{}, err
	}
	return newTask, nil
}

// CompleteTask marca una tarea como completada por su ID.
func CompleteTask(id int) (bool, error) {
	mu.Lock()
	defer mu.Unlock()

	tasks, err := LoadTasks()
	if err != nil {
		return false, err
	}

	found := false
	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Completed = true
			found = true
			break
		}
	}

	if !found {
		return false, fmt.Errorf("tarea con ID %d no encontrada", id)
	}

	if err := SaveTasks(tasks); err != nil {
		return false, err
	}
	return true, nil
}

// GetTasks obtiene todas las tareas (opcionalmente filtradas por completadas).
func GetTasks(showCompleted bool) ([]Task, error) {
	tasks, err := LoadTasks()
	if err != nil {
		return nil, err
	}

	if showCompleted {
		return tasks, nil
	}

	var incompleteTasks []Task
	for _, t := range tasks {
		if !t.Completed {
			incompleteTasks = append(incompleteTasks, t)
		}
	}
	return incompleteTasks, nil
}

Ahora, volvamos a cmd/add.go y modifiquémoslo para usar nuestro nuevo paquete internal/task.

package cmd

import (
	"fmt"
	"go-cli-tool/internal/task"
	"time"

	"github.com/AlecAivazis/survey/v2"
	"github.com/spf13/cobra"
	"github.com/pterm/pterm"
)

var dueDateStr string // Cambiamos el nombre para evitar colisiones

var addCmd = &cobra.Command{
	Use:   "add [description]",
	Short: "Añade una nueva tarea",
	Long:  `Añade una nueva tarea a tu lista de pendientes.`, 
	Run: func(cmd *cobra.Command, args []string) {
		description := ""
		if len(args) > 0 {
			description = args[0]
		} else {
			prompt := &survey.Input{
				Message: "¿Cuál es la descripción de la tarea?",
				Help:    "Ej: Comprar víveres, enviar email, etc.",
			}
			err := survey.AskOne(prompt, &description)
			if err != nil {
				pterm.Error.Printf("Error al obtener la descripción: %v\n", err)
				return
			}
			if description == "" {
				pterm.Warning.Println("La descripción de la tarea no puede estar vacía. Abortando.")
				return
			}
		}

		var parsedDueDate *time.Time
		if dueDateStr != "" {
			d, err := time.Parse("2006-01-02", dueDateStr)
			if err != nil {
				pterm.Error.Printf("Formato de fecha de vencimiento inválido (esperado YYYY-MM-DD): %v\n", err)
				return
			}
			parsedDueDate = &d
		}

		addedTask, err := task.AddTask(description, parsedDueDate)
		if err != nil {
			pterm.Error.Printf("Error al añadir la tarea: %v\n", err)
			return
		}

		pterm.Success.Printf("Tarea añadida con éxito! ID: %d, Descripción: '%s' ✅\n", addedTask.ID, addedTask.Description)
	},
}

func init() {
	rootCmd.AddCommand(addCmd)

	addCmd.Flags().StringVarP(&dueDateStr, "due", "d", "", "Fecha de vencimiento de la tarea (YYYY-MM-DD)")
}

Comando list 📋

Crea cmd/list.go:

package cmd

import (
	"fmt"
	"go-cli-tool/internal/task"
	"time"

	"github.com/spf13/cobra"
	"github.com/pterm/pterm"
)

var showCompleted bool

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "Lista todas las tareas",
	Long:  `Muestra todas las tareas pendientes o completadas.`, 
	Run: func(cmd *cobra.Command, args []string) {
		tasks, err := task.GetTasks(showCompleted)
		if err != nil {
			pterm.Error.Printf("Error al cargar las tareas: %v\n", err)
			return
		}

		if len(tasks) == 0 {
			pterm.Info.Println("¡No hay tareas para mostrar! 🎉")
			return
		}

		// Preparar datos para la tabla
		data := [][]string{{"ID", "Descripción", "Completada", "Creación", "Vencimiento"}}
		for _, t := range tasks {
			completedStatus := "❌ No"
			if t.Completed {
				completedStatus = "✅ Sí"
			}
			dueDate := "N/A"
			if t.DueDate != nil {
				dueDate = t.DueDate.Format("2006-01-02")
			}
			data = append(data, []string{
				fmt.Sprintf("%d", t.ID),
				t.Description,
				completedStatus,
				t.CreatedAt.Format("2006-01-02 15:04"),
				dueDate,
			})
		}

		pterm.DefaultTable.WithHasHeader().WithData(data).Render()
	},
}

func init() {
	rootCmd.AddCommand(listCmd)

	listCmd.Flags().BoolVarP(&showCompleted, "all", "a", false, "Mostrar también las tareas completadas")
}

Comando complete

Crea cmd/complete.go:

package cmd

import (
	"fmt"
	"go-cli-tool/internal/task"
	"strconv"

	"github.com/spf13/cobra"
	"github.com/pterm/pterm"
)

var completeCmd = &cobra.Command{
	Use:   "complete [task ID]",
	Short: "Marca una tarea como completada",
	Long:  `Marca una tarea específica como completada usando su ID.`, 
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		taskID, err := strconv.Atoi(args[0])
		if err != nil {
			pterm.Error.Printf("ID de tarea inválido: %v\n", err)
			return
		}

		completed, err := task.CompleteTask(taskID)
		if err != nil {
			pterm.Error.Printf("Error al completar la tarea %d: %v\n", taskID, err)
			return
		}

		if completed {
			pterm.Success.Printf("Tarea %d marcada como completada! 🎉\n", taskID)
		} else {
			pterm.Warning.Printf("La tarea %d no se pudo completar (¿ya estaba completada o no existía?).\n", taskID)
		}
	},
}

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

🧪 Pruebas y Despliegue de tu CLI

Compilación y Prueba 🧪

Una vez que hayas desarrollado tu CLI, el siguiente paso es compilarla y probarla.

Para compilar un binario ejecutable:

go build -o go-cli-tool .

Esto creará un archivo go-cli-tool (o go-cli-tool.exe en Windows) en tu directorio actual. Luego, puedes ejecutarlo directamente:

./go-cli-tool add "Aprender Go CLI"
./go-cli-tool list
./go-cli-tool complete 1
./go-cli-tool list -a

Para compilaciones cross-platform:

GOOS=linux GOARCH=amd64 go build -o go-cli-tool-linux-amd64 .
GOOS=windows GOARCH=amd64 go build -o go-cli-tool-windows-amd64.exe .
GOOS=darwin GOARCH=arm64 go build -o go-cli-tool-darwin-arm64 .

Despliegue de tu CLI 🚀

El despliegue de una CLI en Go es sencillo debido a los binarios estáticos. Simplemente distribuye el binario compilado. Para que sea accesible globalmente, puedes:

  1. Moverlo a un directorio en el PATH: Por ejemplo, /usr/local/bin/ en Linux/macOS o un directorio en tu Path de Windows.
sudo mv go-cli-tool /usr/local/bin/
  1. Usar un gestor de paquetes: Para proyectos más grandes, puedes crear paquetes .deb, .rpm o usar herramientas como Homebrew para macOS/Linux o Scoop para Windows.
🔥 Importante: Asegúrate de que el archivo de configuración por defecto (`.go-cli-tool.yaml` o similar) y el archivo de datos (`tasks.json`) estén ubicados en un lugar predecible y accesible para el usuario. El uso de `os.UserHomeDir()` ayuda en esto.

💡 Buenas Prácticas y Consejos Adicionales

Aquí tienes algunas buenas prácticas para llevar tu CLI al siguiente nivel:

  • Documentación de Ayuda: Cobra genera automáticamente una ayuda exhaustiva. Asegúrate de que Short y Long descripciones de tus comandos sean claras y útiles.
  • Validación de Entrada: Implementa validaciones robustas para las entradas del usuario (ej. cobra.ExactArgs, cobra.MinimumNArgs, o validaciones personalizadas).
  • Manejo de Errores: Utiliza el paquete errors de Go para crear errores significativos y manejarlos apropiadamente, informando al usuario cuando algo va mal.
  • Contexto: Pasa un context.Context a tus funciones para permitir la cancelación o timeouts, especialmente en operaciones de red o larga duración.
  • Pruebas Unitarias e Integración: Escribe pruebas para tus comandos y lógica de negocio. Go facilita la escritura de pruebas con su paquete testing.
  • Estructura del Proyecto: Mantén tu código organizado en paquetes (cmd para comandos, internal para lógica interna, pkg para librerías reutilizables).
graph TD
    A[Usuario Ejecuta CLI] --> B{Comando Raíz (`rootCmd`)}
    B --> C{Parsear Banderas Globales y Configuración (`Viper`)}
    B --> D{Comando `add`}
    B --> E{Comando `list`}
    B --> F{Comando `complete`}
    B --> G{Otros Comandos}
    D --> D1{Validar Argumentos}
    D1 --> D2{Preguntar Interactivamente (`survey`)}
    D2 --> D3{Llamar a Lógica de Negocio (`internal/task`)}
    D3 --> D4{Mostrar Éxito/Error (`pterm`)}
    E --> E1{Cargar Tareas (`internal/task`)}
    E1 --> E2{Filtrar Tareas}
    E2 --> E3{Mostrar en Tabla (`pterm`)}
    F --> F1{Parsear ID de Tarea}
    F1 --> F2{Llamar a Lógica de Negocio (`internal/task`)}
    F2 --> F3{Mostrar Éxito/Error (`pterm`)}
Usuario main.go rootCmd initConfig & Flags Comandos CLI 'add' | 'list' | 'complete' internal/task (Lógica) Interactividad (survey) Salida Visual (pterm)

Resumen de Características Implementadas:

CaracterísticaBiblioteca UsadaDescripción
---------
Estructura de ComandosCobraDefine comandos raíz y subcomandos, parsea argumentos y banderas.
Gestión de ConfiguraciónViperLee configuración desde archivos, variables de entorno, etc.
---------
Salida EleganteptermColores, tablas, mensajes de estado (info, success, error).
Entrada InteractivasurveyPreguntas al usuario, selección múltiple, confirmaciones.
---------
Persistencia de DatosJSON (Manual)Almacenamiento y recuperación de tareas en un archivo JSON.
Manejo de ErroresGo errorsGestión de errores estándar de Go.
100% Completado

Conclusión 🎉

Has llegado al final de este tutorial. Ahora tienes los conocimientos y la base para construir tus propias herramientas de línea de comandos en Go. Hemos cubierto la estructuración con Cobra, la gestión de configuración con Viper, la mejora de la experiencia de usuario con pterm y survey, y un sistema básico de persistencia de datos. El ecosistema de Go ofrece una gran cantidad de herramientas para hacer tus CLIs aún más potentes y robustas. ¡Anímate a explorar y construir tu próxima gran herramienta!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!