StackPractices
intermediate Por Mathias Paulenko

Parseo de argumentos CLI en Python, JS, Java, Go y Rust

Construí herramientas de línea de comandos que manejen flags, argumentos posicionales, subcomandos y validación en Python, JS, Java, Go y Rust.

Temas: devops

Las herramientas de línea de comandos siguen corriendo la mayoría de flujos de desarrollo, automatización DevOps y pipelines de procesamiento de datos. Una buena CLI te da subcomandos claros, defaults razonables, errores útiles y ayuda que se genera sola.

Construí y mantuve herramientas CLI en Python, Go y Rust para automatización de deploys, pipelines de datos y tooling interno de desarrollo. La lección más grande que aprendí: agarrá un framework desde el inicio. Parsear argumentos a mano se siente rápido el día uno, pero para cuando necesitás texto de --help, subcomandos o shell completion, una librería como argparse, commander.js o clap ya lo resuelve. Para secrets y config, combiná tu CLI con variables de entorno y parseo de archivos de config — nunca hardcodees credenciales en flags.

A continuación hay ejemplos concretos del mismo CLI deploy en Python, JavaScript, Java, Go y Rust, usando las librerías que los equipos usan en la práctica.

Cuándo Usarlo

  • Estás construyendo herramientas internas, scripts de deploy o utilidades de automatización.
  • Necesitás un pipeline de procesamiento o ETL que los operadores lancen desde el terminal.
  • Querés exponer funcionalidad de la app a sysadmins o pipelines de CI/CD.
  • Tu script creció más allá de un par de argumentos, así que un parser lo mantiene manejable.
  • Necesitás feature flags toggled via CLI switches en diferentes entornos.

Cuándo NO Usarlo

  • El script es de una sola vez con una o dos flags; los argumentos de shell podrían alcanzar.
  • Una web UI o dashboard tiene más sentido para usuarios no técnicos que no deberían tocar un terminal.
  • La herramienta depende de prompts interactivos en entornos sin TTY.

Solución

Python (argparse)

import argparse

def main():
    parser = argparse.ArgumentParser(description="Deploy CLI tool")
    parser.add_argument("environment", choices=["dev", "staging", "prod"],
                        help="Target environment")
    parser.add_argument("--version", default="latest",
                        help="App version to deploy")
    parser.add_argument("--dry-run", action="store_true",
                        help="Simulate without changes")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Enable verbose output")

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

if __name__ == "__main__":
    main()

Python (Typer)

import typer

app = typer.Typer()

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

if __name__ == "__main__":
    app()

JavaScript (commander.js)

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

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

program
  .command("deploy <environment>")
  .description("Deploy to an environment")
  .option("-v, --version <ver>", "App version", "latest")
  .option("--dry-run", "Simulate without changes", false)
  .option("--verbose", "Verbose output", false)
  .action((environment, options) => {
    console.log(`Deploying ${options.version} to ${environment}`);
    if (options.dryRun) console.log("(dry run mode)");
  });

program.parse();

JavaScript (yargs)

const yargs = require("yargs/yargs");
const { hideBin } = require("yargs/helpers");

yargs(hideBin(process.argv))
  .command("deploy <env>", "Deploy to environment", (yargs) => {
    return yargs
      .positional("env", { describe: "Target environment",
                           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, "You need at least one command")
  .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 for app deployments",
         version = "1.0.0",
         mixinStandardHelpOptions = true)
public class DeployCli implements Callable<Integer> {

    @Parameters(index = "0", description = "Target environment")
    private String environment;

    @Option(names = {"-v", "--version"}, defaultValue = "latest",
            description = "App version")
    private String version;

    @Option(names = "--dry-run", description = "Simulate without changes")
    private boolean dryRun;

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

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

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

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.ExactArgs(1),
        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)
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

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)");
            }
        }
    }
}

Explicación

Un framework de CLI se encarga de lo aburrido para que te concentres en la lógica de la herramienta:

flowchart diagram: argv
  • Parsing separa deploy prod --version 2.1.0 --dry-run en un objeto estructurado.
  • Validación atrapa choices inválidos, flags requeridos faltantes y type mismatches antes de que tu handler se ejecute.
  • Generación de ayuda construye --help a partir de las definiciones.
  • Subcomandos organizan herramientas complejas (git push, git pull, git log).
  • Códigos de salida devuelven 0 en éxito y distinto de cero en error, para que CI/CD y scripts de shell reaccionen.

Referencia de exit codes

Códigos de salida estándar que la mayoría de frameworks CLI siguen:

CódigoSignificadoCuándo usarlo
0ÉxitoEl comando completó sin errores
1Fallo generalError de lógica de la app, excepción inesperada
2Mal usoArgumentos inválidos, flags requeridos faltantes, choices inválidos
126Comando no ejecutableProblema de permisos
127Comando no encontradoTypo, binario faltante
130Interrumpido por SIGINTEl usuario presionó Ctrl+C

Una vez pasé dos horas debuggeando un pipeline de CI que “tenía éxito” incluso cuando el CLI de deploy fallaba — la herramienta devolvía exit code 0 en errores. Siempre devolvé non-zero en fallos; tu sistema de CI/CD depende de eso.

Debuggear y testear CLIs

Para debuggear, agregá un flag --verbose que imprima estado intermedio a stderr. No contamines stdout con diagnósticos — otras herramientas pueden pipear tu output. Para testear:

  1. Separá lógica del cableado CLI — mantené la lógica de negocio en funciones puras que no toquen argv o stdout. Testealas directamente.
  2. Tests de integración vía subprocess — lanzá el binario compilado con subprocess.run() en Python o exec.Command() en Go. Assertá sobre exit code y stdout/stderr.
  3. Snapshot test de --help — como el texto de ayuda funciona como docs para el usuario, un snapshot test atrapa cambios accidentales cuando agregás o quitás flags.
  4. --dry-run en CI — cableá --dry-run como step de CI. Si sale non-zero, atrapás una config mala antes de que llegue a producción.

Shell completion

Cobra (Go) y clap (Rust) generan scripts de shell completion automáticamente:

# Cobra: generar bash completion
deploy-cli completion bash > /etc/bash_completion.d/deploy-cli

# clap: generar completion vía clap_complete
deploy-cli completions --shell bash

picocli (Java) soporta completion vía picocli.AutoComplete. Commander.js y yargs tienen plugins de la comunidad. Shell completion es un esfuerzo chico que se paga cada vez que un usuario tipea el nombre de tu herramienta.

Variantes

LenguajeLibreríaEstiloIdeal para
PythonargparseStdlib, imperativoSin dependencias, scripts simples
PythontyperType hints, modernoDesarrollo rápido, docs automáticas
JavaScriptcommander.jsCadena fluidaCLI Node.js, middleware
JavaScriptyargsDeclarativo, validaciónCLIs complejos, subcomandos anidados
JavapicocliAnotaciones, GraalVMEnterprise, imágenes nativas
GocobraEstilo stdlib, subcomandosCLIs Go, shell completion
RustclapMacros deriveType-safe, binarios rápidos

Buenas Prácticas

  • Proveé --help y --version para que los usuarios no necesiten leer el código fuente.
  • Devolvé códigos de salida correctos: 0 éxito, 1 error general, 2 mal uso, 130 para SIGINT.
  • Soportá - para stdin/stdout para que los pipes funcionen: cat data.csv | mytool process - > output.json.
  • Validá temprano y fallá rápido; imprimir un mensaje claro con lo esperado y lo recibido.
  • Mantené secretos en variables de entorno, no en argumentos --api-key.

Errores Comunes

  • Imprimir Error: invalid argument sin contexto. Decile al usuario qué se esperaba y qué pasó.
  • Hacer una herramienta con 20 flags en vez de varios subcomandos.
  • Hardcodear rutas y asumir el entorno de desarrollo local.
  • Enviar progreso y diagnósticos a stdout en lugar de stderr.
  • Permitir valores inválidos como --replicas=-5 y que lleguen a la lógica de la app.

Ver También

Preguntas frecuentes

¿Uso un framework o parseo a mano?

Usá un framework. argparse, commander.js, picocli, cobra y clap manejan comillas, escapes, flags desconocidos y formateo de ayuda. El tiempo que ahorrás supera con creces el costo de la dependencia.

¿Cómo combino archivos de configuración con argumentos CLI?

Cargá un archivo de configuración como default y dejá que los argumentos de CLI sobrescriban valores específicos. El orden de precedencia es: args CLI > vars de entorno > archivo de config > defaults hardcodeados.

¿Cómo testeo una herramienta CLI?

Mantené la lógica de negocio separada del cableado de la CLI. Testeá las funciones core directamente, luego agregá algunos tests de integración que corran el binario con subprocess. En Java, testeá el método call() de la clase picocli; en Rust, la lógica del match de Commands.

¿Cómo distribuyo mi herramienta CLI?

La distribución depende del lenguaje. Python se distribuye vía pip o pipx desde PyPI. JavaScript va por npm install -g o npx. Go produce un binario único — distribuílo con go install o GitHub Releases. Rust usa cargo install desde crates.io. Java puede compilar a una imagen nativa de GraalVM para startup rápido, o distribuirse como JAR.

¿Por qué mi CLI sale con exit code 2?

Exit code 2 significa que el usuario invocó la herramienta mal — un argumento inválido, un flag requerido faltante o un choice inválido. La mayoría de los frameworks (argparse, cobra, clap) devuelven 2 automáticamente cuando el parseo falla. Si estás devolviendo 1 para argumentos malos, cambiá a 2 para que los scripts de shell puedan distinguir "error del usuario" de "error de la aplicación".

¿Cuál es la diferencia entre flags y argumentos posicionales?

Los argumentos posicionales se identifican por su posición en la línea de comandos (deploy prod), mientras que los flags son nombrados (deploy --env prod). Usá argumentos posicionales para el sujeto principal del comando (el entorno, el path del archivo) y flags para modificadores opcionales (version, verbose, dry-run). Si tenés más de dos argumentos posicionales, considerá reestructurar en subcomandos.

¿Puedo usar variables de entorno junto con flags CLI?

Sí. La precedencia estándar es flags CLI > env vars > archivo de config > defaults hardcodeados. Esto te permite setear defaults sensatos en un archivo de config, sobrescribir por entorno vía env vars, y sobrescribir por invocación vía flags. Mirá la receta de variables de entorno para patrones de carga y validación de env vars.