CLI Tool with Argument Parsing
How to build a professional command-line interface with argument parsing, flags, and subcommands.
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-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 |
Best Practices
- 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.
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.