Skip to content
StackPractices
intermediate Por Mathias Paulenko

Herramienta CLI con Parseo de Argumentos

Cómo construir una interfaz de línea de comandos profesional con parseo de argumentos, flags y subcomandos.

Temas: devops

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión General

Las herramientas de línea de comandos son la columna vertebral de flujos de trabajo de desarrolladores, automatización DevOps y pipelines de procesamiento de datos. Un CLI bien diseñado tiene subcomandos claros, defaults sensatos, mensajes de error útiles y ayuda auto-generada. A continuacion se cubre la construcción de herramientas CLI profesionales con parseo de argumentos, validación y subcomandos en Python, JavaScript y Java.

Cuándo Usar

Usa este recurso cuando:

  • Construyas herramientas internas para desarrolladores, scripts de deployment o utilidades de automatización. Consulta Bash Scripting Automation para automatización basada en shell.
  • Crees pipelines de procesamiento de datos o ETL disparados desde la terminal. Consulta Parse JSON para manejo de datos estructurados.
  • Expongas funcionalidad de aplicación a sysadmins y pipelines CI/CD. Consulta GitHub Actions para integración de pipelines.
  • Escribas scripts que necesitan más que unos pocos argumentos para mantenerse manejables. Consulta Environment Variables para configuración externalizada.

Solución

Python (argparse + typer)

import argparse
import sys

# argparse clásico
def main():
    parser = argparse.ArgumentParser(description="Deploy CLI tool")
    parser.add_argument("environment", choices=["dev", "staging", "prod"], help="Entorno objetivo")
    parser.add_argument("--version", default="latest", help="Versión de app a deployar")
    parser.add_argument("--dry-run", action="store_true", help="Simular sin cambios")
    parser.add_argument("-v", "--verbose", action="store_true", help="Salida verbose")

    args = parser.parse_args()
    print(f"Deploying {args.version} to {args.environment}")
    if args.dry_run:
        print("(modo dry run)")

if __name__ == "__main__":
    main()

# Alternativa moderna: Typer (type hints, docs auto)
import typer
app = typer.Typer()

@app.command()
def deploy(environment: str, version: str = "latest", dry_run: bool = False):
    typer.echo(f"Deploying {version} to {environment}")
    if dry_run:
        typer.echo("(modo dry run)")

if __name__ == "__main__":
    app()

JavaScript (commander.js + yargs)

const { Command } = require("commander");
const program = new Command();

program
  .name("deploy-cli")
  .description("CLI para deployments de app")
  .version("1.0.0");

program
  .command("deploy <environment>")
  .description("Deployar a un entorno")
  .option("-v, --version <ver>", "Versión de app", "latest")
  .option("--dry-run", "Simular sin cambios", false)
  .option("--verbose", "Salida verbose", false)
  .action((environment, options) => {
    console.log(`Deploying ${options.version} to ${environment}`);
    if (options.dryRun) console.log("(modo dry run)");
  });

program.parse();

// Alternativa: yargs con validación
const yargs = require("yargs/yargs");
const { hideBin } = require("yargs/helpers");

yargs(hideBin(process.argv))
  .command("deploy <env>", "Deployar a entorno", (yargs) => {
    return yargs
      .positional("env", { describe: "Entorno objetivo", choices: ["dev", "staging", "prod"] })
      .option("version", { alias: "v", default: "latest" })
      .option("dry-run", { type: "boolean", default: false });
  }, (argv) => {
    console.log(`Deploying ${argv.version} to ${argv.env}`);
  })
  .demandCommand(1, "Necesitas al menos un comando")
  .help()
  .argv;

Java (picocli)

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;

@Command(name = "deploy-cli", description = "CLI para deployments de app", version = "1.0.0")
public class DeployCli implements Callable<Integer> {

    @Parameters(index = "0", description = "Entorno objetivo", arity = "1")
    private String environment;

    @Option(names = {"-v", "--version"}, description = "Versión de app", defaultValue = "latest")
    private String version;

    @Option(names = "--dry-run", description = "Simular sin cambios")
    private boolean dryRun;

    @Option(names = {"-V", "--verbose"}, description = "Salida verbose")
    private boolean verbose;

    @Override
    public Integer call() {
        System.out.printf("Deploying %s to %s%n", version, environment);
        if (dryRun) System.out.println("(modo dry run)");
        return 0;
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new DeployCli()).execute(args);
        System.exit(exitCode);
    }
}

Explicación

Un buen framework CLI maneja las partes aburridas para que te enfoques en la lógica de negocio:

  • Parseo: Divide deploy prod --version 2.1.0 --dry-run en un objeto estructurado
  • Validación: Rechaza entornos inválidos, enforce flags requeridos, valida tipos (número, booleano, lista de choices)
  • Generación de ayuda: Construye automáticamente la salida --help desde tus definiciones
  • Subcomandos: Organiza herramientas complejas en comandos lógicos (git push, git pull, git log)
  • Códigos de salida: Retorna 0 en éxito y distinto de cero en error para que CI/CD y shell scripts reaccionen correctamente

Variantes

LenguajeLibreríaEstiloIdeal Para
PythonargparseStdlib, imperativoSin dependencias, scripts
PythontyperType hints, modernoDesarrollo rápido, docs auto
JavaScriptcommander.jsAPI fluent chainHerramientas Node.js, middleware
JavaScriptyargsDeclarativo, validaciónCLIs complejos, subcomandos anidados
JavapicocliAnotaciones, GraalVM nativeEnterprise, compilación native-image
JavaApache Commons CLIBuilder patternProyectos Java legacy

Lo que funciona

  • Proporciona --help y --version: Cada CLI debe auto-documentarse. Los usuarios nunca deberían necesitar leer el código fuente para entender el uso.
  • Usa códigos de salida correctamente: Retorna 0 en éxito, 1 en errores generales, 2 en mal uso y 130 para SIGINT (Ctrl+C). CI/CD depende de esto.
  • Soporta - para stdin/stdout: cat data.csv | mytool process - > output.json es la forma Unix. No fuerces archivos temporales.
  • Valida temprano, falla rápido: Verifica argumentos, existencia de archivos y permisos antes de hacer trabajo real. Imprime mensajes de error claros.
  • Usa variables de entorno para secretos: API keys y tokens van en MYTOOL_API_KEY, no en argumentos --api-key que filtran al historial del shell.

Errores Comunes

  • Mensajes de error pobres: Error: invalid argument no dice nada al usuario. Di Error: --count debe ser un entero positivo, recibió "abc".
  • Sin subcomandos para herramientas complejas: Una herramienta con 20 flags es más difícil de usar que una con 4 subcomandos de 5 flags cada uno.
  • Rutas y defaults hardcodeados: Asume que la herramienta corre en CI, Docker y Windows. Usa rutas relativas y overrides por variable de entorno.
  • Ignorar stderr: Imprime progreso y diagnósticos a stderr para que stdout permanezca limpio para piping a otras herramientas.
  • Sin validación de entrada: Aceptar deploy prod --replicas=-5 crasheará más tarde. Valida rangos, enums y rutas de archivo en tiempo de parseo.

Preguntas Frecuentes

Debo usar un framework o parsear argumentos manualmente?

Siempre usa un framework. argparse, commander.js y picocli están probados en batalla y manejan edge cases (quotes, escapes, flags desconocidos, formateo de ayuda) que el slicing manual de process.argv o sys.argv hace mal. La ganancia de productividad supera ampliamente el pequeño costo de dependencia.

Cómo manejo archivos de configuración junto a argumentos CLI?

Carga un archivo de configuración (JSON, YAML, TOML) como defaults, luego deja que los argumentos CLI sobreescriban valores específicos. El orden de precedencia debería ser: args CLI > env vars > archivo config > defaults hardcodeados. Documenta esta jerarquía en tu README.

Cómo pruebo una herramienta CLI?

En Python, usa subprocess.run(["python", "cli.py", "--help"]) o testea las funciones puras detrás del CLI directamente. En JavaScript, importa el handler del comando y llámalo con un objeto argv parseado. En Java, testea el método call() de tu clase picocli independientemente del punto de entrada main(). Mantén la lógica de negocio separada del cableado CLI.

Go (cobra)

package main

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var (
    version  string
    dryRun   bool
    verbose  bool
)

func main() {
    rootCmd := &cobra.Command{
        Use:   "deploy-cli",
        Short: "CLI for app deployments",
        Version: "1.0.0",
    }

    deployCmd := &cobra.Command{
        Use:   "deploy [environment]",
        Short: "Deploy to an environment",
        Args:  cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
        ValidArgs: []string{"dev", "staging", "prod"},
        Run: func(cmd *cobra.Command, args []string) {
            env := args[0]
            fmt.Printf("Deploying %s to %s\n", version, env)
            if dryRun {
                fmt.Println("(dry run mode)")
            }
        },
    }

    deployCmd.Flags().StringVarP(&version, "version", "v", "latest", "App version")
    deployCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate without changes")
    deployCmd.Flags().BoolVarP(&verbose, "verbose", "V", false, "Verbose output")

    rootCmd.AddCommand(deployCmd)
    rootCmd.Execute()
}

Rust (clap)

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "deploy-cli", version = "1.0.0", about = "CLI for app deployments")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Deploy to an environment
    Deploy {
        /// Target environment
        #[arg(value_enum)]
        environment: Environment,

        /// App version
        #[arg(short, long, default_value = "latest")]
        version: String,

        /// Simulate without changes
        #[arg(long)]
        dry_run: bool,
    },
}

#[derive(clap::ValueEnum, Clone)]
enum Environment {
    Dev,
    Staging,
    Prod,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Deploy { environment, version, dry_run } => {
            println!("Deploying {} to {:?}", version, environment);
            if dry_run {
                println!("(dry run mode)");
            }
        }
    }
}

Layering de Archivos de Configuración

import argparse
import json
import os
from pathlib import Path

def load_config():
    """Carga config con precedencia: args CLI > env vars > archivo config > defaults."""
    defaults = {"version": "latest", "dry_run": False, "verbose": False}

    # Cargar archivo de config
    config_path = Path(os.environ.get("DEPLOY_CLI_CONFIG", ".deploy-cli.json"))
    if config_path.exists():
        with open(config_path) as f:
            defaults.update(json.load(f))

    # Env vars sobreescriben archivo de config
    if os.environ.get("DEPLOY_CLI_VERSION"):
        defaults["version"] = os.environ["DEPLOY_CLI_VERSION"]

    # Args CLI sobreescriben todo
    parser = argparse.ArgumentParser()
    parser.add_argument("environment", choices=["dev", "staging", "prod"])
    parser.add_argument("--version", default=defaults["version"])
    parser.add_argument("--dry-run", action="store_true", default=defaults["dry_run"])
    return parser.parse_args()

Shell Completion

# Python (Typer) - genera scripts de completion
$ deploy-cli --install-completion bash
# Añadir a ~/.bashrc: eval "$(_DEPLOY_CLI_COMPLETE=bash_source deploy-cli)"

# Go (Cobra) - completion integrado
$ deploy-cli completion bash > /etc/bash_completion.d/deploy-cli

# JavaScript (Commander) - vía oclif o custom
$ deploy-cli completion > ~/.zsh/completions/_deploy-cli

Barras de Progreso y Output Interactivo

# Python: rich para barras de progreso
from rich.progress import Progress
from rich.console import Console

console = Console()

with Progress() as progress:
    task = progress.add_task("[cyan]Deploying...", total=100)
    for step in range(100):
        # Simular trabajo
        progress.update(task, advance=1)
    console.print("[green]Deployment complete![/green]")
// JavaScript: ora para spinners
const ora = require("ora");

const spinner = ora("Deploying...").start();
try {
    await deploy(environment, version);
    spinner.succeed("Deployment complete!");
} catch (err) {
    spinner.fail(`Deployment failed: ${err.message}`);
    process.exit(1);
}

Mejores Prácticas Adicionales

  1. Soporta flags --quiet y --json. Los pipelines de CI/CD necesitan output machine-readable:
parser.add_argument("--json", action="store_true", help="Output JSON for CI/CD")
parser.add_argument("--quiet", action="store_true", help="Suppress non-error output")
  1. Usa color con moderación. Detecta si stdout es un TTY antes de usar colores:
import sys
import shutil

def color(text, color_code):
    if shutil.isatty(sys.stdout):
        return f"\033[{color_code}m{text}\033[0m"
    return text

print(color("Success", "32"))  # Verde solo en TTY
  1. Proporciona --dry-run para comandos destructivos. Muestra qué pasaría sin ejecutar:
$ deploy-cli deploy prod --dry-run
Would deploy version 2.1.0 to prod
Would run 3 migrations
Would restart 5 pods
(dry run mode - no changes made)

Errores Comunes Adicionales

  1. No manejar SIGINT gracefulmente. Ctrl+C debe limpiar, no dejar trabajo a medias:
import signal
import sys

def handle_sigint(sig, frame):
    print("\nAborting...")
    cleanup()
    sys.exit(130)  # 128 + SIGINT(2)

signal.signal(signal.SIGINT, handle_sigint)
  1. Usar print() para errores. Los mensajes de error van a stderr, no stdout:
import sys

# Mal
print(f"Error: {e}")

# Bien
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

FAQ

¿Cómo distribuyo mi herramienta CLI?

  • Python: pip install vía PyPI, o pipx para herramientas standalone
  • JavaScript: npm install -g vía npm, o npx para ejecuciones one-off
  • Go: Binario único vía go install, Homebrew, o GitHub Releases
  • Rust: cargo install vía crates.io
  • Java: GraalVM native-image para startup rápido, o JAR vía SDKMAN

¿Cómo añado shell completion?

La mayoría de frameworks generan scripts de completion automáticamente:

# Cobra (Go)
mytool completion bash > /etc/bash_completion.d/mytool

# Typer (Python)
mytool --install-completion zsh

# Commander (JS) - vía oclif
mytool completion --shell zsh

¿Debo usar flags largos o cortos?

Ambos. Flags cortos (-v) para uso común, flags largos (--version) para scripts y documentación. Siempre proporciona --help que liste ambos. Nunca uses flags cortos que conflictúen con convenciones estándar (-h para help, -V para version).

Tips de Rendimiento

  1. Minimiza el tiempo de startup. Las herramientas CLI deben iniciar en menos de 100ms:
# Lento: importa todo al startup
import pandas  # 500ms+ de import

# Rápido: lazy import
def process_data():
    import pandas  # Solo cuando se necesita
    ...
  1. Usa lenguajes compilados para hot paths. Go y Rust producen binarios únicos con startup de milisegundos:
# Go: binario único, sin runtime
go build -o deploy-cli main.go

# Rust: binario optimizado
cargo build --release
  1. Cachéa operaciones costosas. Scans de archivos, llamadas a API, y queries a base de datos deben ser cacheadas:
import hashlib
import json
from pathlib import Path

def cached_api_call(endpoint, cache_dir=".cache"):
    cache_key = hashlib.md5(endpoint.encode()).hexdigest()
    cache_file = Path(cache_dir) / f"{cache_key}.json"
    if cache_file.exists():
        return json.loads(cache_file.read_text())
    result = api_call(endpoint)
    cache_file.parent.mkdir(exist_ok=True)
    cache_file.write_text(json.dumps(result))
    return result
  1. Paraleliza operaciones independientes. Usa threads o async para llamadas a API concurrentes:
import concurrent.futures

def deploy_multiple(services):
    with concurrent.futures.ThreadPoolExecutor() as executor:
        results = list(executor.map(deploy_service, services))
    return results