CLI Tool with Argument Parsing
How to build a professional command-line interface with argument parsing, flags, and subcommands.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
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 approach handles 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. See Bash Scripting Automation for shell-based automation.
- Creating data processing or ETL pipelines triggered from the terminal. See Parse JSON for structured data handling.
- Exposing application functionality to sysadmins and CI/CD pipelines. See GitHub Actions for pipeline integration.
- Writing scripts that need more than a few arguments to stay maintainable. See Environment Variables for externalized configuration.
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-runinto a structured object - Validation: Rejects invalid environments, enforces required flags, validates types (number, boolean, choice list)
- Help generation: Auto-builds
--helpoutput from your definitions - Subcommands: Organizes complex tools into logical commands (
git push,git pull,git log) - Exit codes: Returns
0on success and non-zero on error so CI/CD and shell scripts can react properly
Variants
| Language | Library | Style | Best For |
|---|---|---|---|
| Python | argparse | Stdlib, imperative | No dependencies, scripts |
| Python | typer | Type-hint driven, modern | Rapid development, auto docs |
| JavaScript | commander.js | Fluent chain API | Node.js CLI tools, middleware |
| JavaScript | yargs | Declarative, validation | Complex CLIs, nested subcommands |
| Java | picocli | Annotations, GraalVM native | Enterprise, native-image compilation |
| Java | Apache Commons CLI | Builder pattern | Legacy Java projects |
What Works
- Provide
--helpand--version: Every CLI should self-document. Users should never need to read the source to understand usage. - Use exit codes correctly: Return
0for success,1for general errors,2for misuse, and130for SIGINT (Ctrl+C). CI/CD depends on this. - Support
-for stdin/stdout:cat data.csv | mytool process - > output.jsonis 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-keyarguments that leak to shell history.
Common Mistakes
- Poor error messages:
Error: invalid argumenttells the user nothing. SayError: --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
stderrsostdoutstays clean for piping to other tools. - No input validation: Accepting
deploy prod --replicas=-5will 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.
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)");
}
}
}
}
Configuration File Layering
import argparse
import json
import os
from pathlib import Path
def load_config():
"""Load config with precedence: CLI args > env vars > config file > defaults."""
defaults = {"version": "latest", "dry_run": False, "verbose": False}
# Load config file
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 override config file
if os.environ.get("DEPLOY_CLI_VERSION"):
defaults["version"] = os.environ["DEPLOY_CLI_VERSION"]
# CLI args override everything
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) - generates completion scripts
$ deploy-cli --install-completion bash
# Add to ~/.bashrc: eval "$(_DEPLOY_CLI_COMPLETE=bash_source deploy-cli)"
# Go (Cobra) - built-in completion
$ deploy-cli completion bash > /etc/bash_completion.d/deploy-cli
# JavaScript (Commander) - via oclif or custom
$ deploy-cli completion > ~/.zsh/completions/_deploy-cli
Progress Bars and Interactive Output
# Python: rich for progress bars
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):
# Simulate work
progress.update(task, advance=1)
console.print("[green]Deployment complete![/green]")
// JavaScript: ora for 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);
}
Additional Best Practices
- Support
--quietand--jsonflags. CI/CD pipelines need machine-readable output:
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")
- Use color sparingly. Detect if stdout is a TTY before using colors:
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")) # Green only in TTY
- Provide
--dry-runfor destructive commands. Show what would happen without executing:
$ 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)
Additional Common Mistakes
- Not handling SIGINT gracefully. Ctrl+C should clean up, not leave half-done work:
import signal
import sys
def handle_sigint(sig, frame):
print("\nAborting...")
cleanup()
sys.exit(130) # 128 + SIGINT(2)
signal.signal(signal.SIGINT, handle_sigint)
- Using
print()for errors. Error messages go tostderr, notstdout:
import sys
# Wrong
print(f"Error: {e}")
# Right
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
FAQ
How do I distribute my CLI tool?
- Python:
pip installvia PyPI, orpipxfor standalone tools - JavaScript:
npm install -gvia npm, ornpxfor one-off runs - Go: Single binary via
go install, Homebrew, or GitHub Releases - Rust:
cargo installvia crates.io - Java: GraalVM native-image for fast startup, or JAR via SDKMAN
How do I add shell completion?
Most frameworks generate completion scripts automatically:
# Cobra (Go)
mytool completion bash > /etc/bash_completion.d/mytool
# Typer (Python)
mytool --install-completion zsh
# Commander (JS) - via oclif
mytool completion --shell zsh
Should I use long or short flags?
Both. Short flags (-v) for common use, long flags (--version) for scripts and documentation. Always provide --help that lists both. Never use short flags that conflict with standard conventions (-h for help, -V for version).
Performance Tips
- Minimize startup time. CLI tools should start in under 100ms:
# Slow: imports everything at startup
import pandas # 500ms+ import
# Fast: lazy import
def process_data():
import pandas # Only when needed
...
- Use compiled languages for hot paths. Go and Rust produce single binaries with millisecond startup:
# Go: single binary, no runtime
go build -o deploy-cli main.go
# Rust: optimized binary
cargo build --release
- Cache expensive operations. File scans, API calls, and database queries should be cached:
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
- Parallelize independent operations. Use threads or async for concurrent API calls:
import concurrent.futures
def deploy_multiple(services):
with concurrent.futures.ThreadPoolExecutor() as executor:
results = list(executor.map(deploy_service, services))
return results Related Resources
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.