Herramienta CLI con Parseo de Argumentos
Cómo construir una interfaz de línea de comandos profesional con parseo de argumentos, flags y subcomandos.
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-runen 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
--helpdesde tus definiciones - Subcomandos: Organiza herramientas complejas en comandos lógicos (
git push,git pull,git log) - Códigos de salida: Retorna
0en éxito y distinto de cero en error para que CI/CD y shell scripts reaccionen correctamente
Variantes
| Lenguaje | Librería | Estilo | Ideal Para |
|---|---|---|---|
| Python | argparse | Stdlib, imperativo | Sin dependencias, scripts |
| Python | typer | Type hints, moderno | Desarrollo rápido, docs auto |
| JavaScript | commander.js | API fluent chain | Herramientas Node.js, middleware |
| JavaScript | yargs | Declarativo, validación | CLIs complejos, subcomandos anidados |
| Java | picocli | Anotaciones, GraalVM native | Enterprise, compilación native-image |
| Java | Apache Commons CLI | Builder pattern | Proyectos Java legacy |
Lo que funciona
- Proporciona
--helpy--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
0en éxito,1en errores generales,2en mal uso y130para SIGINT (Ctrl+C). CI/CD depende de esto. - Soporta
-para stdin/stdout:cat data.csv | mytool process - > output.jsones 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-keyque filtran al historial del shell.
Errores Comunes
- Mensajes de error pobres:
Error: invalid argumentno dice nada al usuario. DiError: --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
stderrpara questdoutpermanezca limpio para piping a otras herramientas. - Sin validación de entrada: Aceptar
deploy prod --replicas=-5crasheará 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
- Soporta flags
--quiety--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")
- 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
- Proporciona
--dry-runpara 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
- 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)
- Usar
print()para errores. Los mensajes de error van astderr, nostdout:
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 installvía PyPI, opipxpara herramientas standalone - JavaScript:
npm install -gvía npm, onpxpara ejecuciones one-off - Go: Binario único vía
go install, Homebrew, o GitHub Releases - Rust:
cargo installví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
- 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
...
- 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
- 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
- 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 Recursos Relacionados
Background Jobs
How to schedule and run background jobs using cron, task queues, and workers.
RecipeEnvironment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
RecipeHealth Check Endpoint
How to implement a production-ready health check endpoint for monitoring and load balancers.
RecipeCron Jobs
How to schedule and manage recurring tasks using cron syntax across Linux, Python, and Node.js.
PatternAbstract Factory Pattern
Create families of related objects without specifying concrete classes. A creational design pattern for consistent object families.
RecipeFeature Flags
How to implement feature toggles to safely roll out, test, and rollback functionality without deploying code.
RecipeGenerate Sitemaps Live
How to build and serve live XML sitemaps from your application data, with multi-language support, pagination, and automatic lastmod dates.