Skip to content
SP StackPractices
intermediate

CLI Tool with Argument Parsing

How to build a professional command-line interface with argument parsing, flags, and subcommands.

Topics: devops

Overview

Command-line tools are the backbone of developer workflows, DevOps automation, and data processing pipelines. A well-designed CLI has clear subcommands, sensible defaults, helpful error messages, and auto-generated help. This recipe covers building professional CLI tools with argument parsing, validation, and subcommands in Python, JavaScript, and Java.

When to Use

Use this resource when:

  • Building internal developer tools, deployment scripts, or automation utilities
  • Creating data processing or ETL pipelines triggered from the terminal
  • Exposing application functionality to sysadmins and CI/CD pipelines
  • Writing scripts that need more than a few arguments to stay maintainable

Solution

Python (argparse + typer)

import argparse
import sys

# Classic 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()

# Modern alternative: Typer (type hints, auto docs)
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("(dry run mode)")

if __name__ == "__main__":
    app()

JavaScript (commander.js + yargs)

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();

// Alternative: yargs with validation
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")
public class DeployCli implements Callable<Integer> {

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

    @Option(names = {"-v", "--version"}, description = "App version", defaultValue = "latest")
    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);
    }
}

Explanation

A good CLI framework handles the boring parts so you can focus on business logic:

  • Parsing: Splits deploy prod --version 2.1.0 --dry-run into a structured object
  • Validation: Rejects invalid environments, enforces required flags, validates types (number, boolean, choice list)
  • Help generation: Auto-builds --help output from your definitions
  • Subcommands: Organizes complex tools into logical commands (git push, git pull, git log)
  • Exit codes: Returns 0 on success and non-zero on error so CI/CD and shell scripts can react properly

Variants

LanguageLibraryStyleBest For
PythonargparseStdlib, imperativeNo dependencies, scripts
PythontyperType-hint driven, modernRapid development, auto docs
JavaScriptcommander.jsFluent chain APINode.js CLI tools, middleware
JavaScriptyargsDeclarative, validationComplex CLIs, nested subcommands
JavapicocliAnnotations, GraalVM nativeEnterprise, native-image compilation
JavaApache Commons CLIBuilder patternLegacy Java projects

Best Practices

  • Provide --help and --version: Every CLI should self-document. Users should never need to read the source to understand usage.
  • Use exit codes correctly: Return 0 for success, 1 for general errors, 2 for misuse, and 130 for SIGINT (Ctrl+C). CI/CD depends on this.
  • Support - for stdin/stdout: cat data.csv | mytool process - > output.json is the Unix way. Don’t force temporary files.
  • Validate early, fail fast: Check arguments, file existence, and permissions before doing any real work. Print clear error messages.
  • Use environment variables for secrets: API keys and tokens belong in MYTOOL_API_KEY, not in --api-key arguments that leak to shell history.

Common Mistakes

  • Poor error messages: Error: invalid argument tells the user nothing. Say Error: --count must be a positive integer, got "abc".
  • No subcommands for complex tools: A tool with 20 flags is harder to use than one with 4 subcommands each having 5 flags.
  • Hardcoding paths and defaults: Assume the tool runs on CI, Docker, and Windows. Use relative paths and environment-variable overrides.
  • Ignoring stderr: Print progress and diagnostics to stderr so stdout stays clean for piping to other tools.
  • No input validation: Accepting deploy prod --replicas=-5 will crash later. Validate ranges, enums, and file paths at parse time.

Frequently Asked Questions

Should I use a framework or parse arguments manually?

Always use a framework. argparse, commander.js, and picocli are battle-tested and handle edge cases (quotes, escapes, unknown flags, help formatting) that manual process.argv or sys.argv slicing gets wrong. The productivity gain far outweighs the tiny dependency cost.

How do I handle configuration files alongside CLI arguments?

Load a config file (JSON, YAML, TOML) as defaults, then let CLI arguments override specific values. The precedence order should be: CLI args > env vars > config file > hardcoded defaults. Document this hierarchy in your README.

How do I test a CLI tool?

In Python, use subprocess.run(["python", "cli.py", "--help"]) or test the pure functions behind the CLI directly. In JavaScript, import the command handler and call it with a parsed argv object. In Java, test the call() method of your picocli class independently of the main() entry point. Keep business logic separate from CLI wiring.