Building CLI Tools: Frameworks and Best Practices
Building CLI Tools: Frameworks and Best Practices
A well-built CLI tool feels invisible -- it does what you expect, gives clear feedback, and gets out of the way. Building one that meets that bar takes more thought than most developers expect. Argument parsing, help text, error handling, config files, colored output, progress indicators, and distribution all need deliberate attention.
This guide covers the major frameworks across languages and the patterns that separate amateur CLIs from professional ones.
Choosing a Language
The language choice for a CLI tool depends on distribution requirements, startup time, and your team's expertise.
| Language | Startup Time | Binary Distribution | Ecosystem | Best For |
|---|---|---|---|---|
| Rust | ~1ms | Single static binary | Clap, indicatif | Performance-critical tools, wide distribution |
| Go | ~5ms | Single static binary | cobra, bubbletea | DevOps tools, Kubernetes ecosystem |
| TypeScript/Node | ~100-200ms | npm package (or pkg/bun compile) | Commander, oclif | Internal tools, JS ecosystem |
| Python | ~50-100ms | pip/pipx (or PyInstaller) | Click, Typer | Scripts, data tools, ML ecosystem |
For wide distribution (open source tools, developer tooling): Rust or Go. Single binaries with no runtime dependency are the gold standard.
For internal tools or ecosystem-specific CLIs: TypeScript or Python. The startup time penalty is acceptable when the tool is used interactively.
Commander.js: Simple TypeScript CLIs
Commander.js is the most widely used Node.js CLI framework. It handles argument parsing, help generation, and subcommands with minimal boilerplate.
Basic Setup
// src/cli.ts
import { Command } from "commander";
const program = new Command();
program
.name("myctl")
.description("Manage my infrastructure")
.version("1.0.0");
program
.command("deploy")
.description("Deploy the application")
.argument("<environment>", "target environment (dev, staging, prod)")
.option("-t, --tag <tag>", "image tag to deploy", "latest")
.option("--dry-run", "show what would be deployed without deploying")
.option("-f, --force", "skip confirmation prompts")
.action(async (environment, options) => {
if (!["dev", "staging", "prod"].includes(environment)) {
console.error(`Error: unknown environment "${environment}"`);
process.exit(1);
}
if (options.dryRun) {
console.log(`Would deploy ${options.tag} to ${environment}`);
return;
}
console.log(`Deploying ${options.tag} to ${environment}...`);
// deployment logic
});
program
.command("status")
.description("Show deployment status")
.option("-e, --environment <env>", "filter by environment")
.option("--json", "output as JSON")
.action(async (options) => {
const status = await getDeploymentStatus(options.environment);
if (options.json) {
console.log(JSON.stringify(status, null, 2));
} else {
printStatusTable(status);
}
});
program.parse();
# Generated help
$ myctl --help
Usage: myctl [options] [command]
Manage my infrastructure
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
deploy <environment> Deploy the application
status Show deployment status
help [command] display help for command
Adding to package.json
{
"name": "myctl",
"version": "1.0.0",
"bin": {
"myctl": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"start": "tsx src/cli.ts"
}
}
Commander.js strengths: Simple API, lightweight (~15 KB), well-documented, handles 90% of CLI needs.
Commander.js weaknesses: No built-in plugin system, no auto-update mechanism, limited help customization.
oclif: Enterprise TypeScript CLIs
oclif (Open CLI Framework) is Salesforce's framework for building large, extensible CLIs. It's what powers the Heroku CLI and Salesforce CLI. If your CLI has dozens of commands, needs plugins, or will be distributed to a large user base, oclif is the right choice.
Project Setup
npx oclif generate myctl
cd myctl
oclif generates a full project structure with TypeScript, testing, CI, and packaging configured.
Defining Commands
Each command is a class in its own file:
// src/commands/deploy.ts
import { Command, Flags, Args } from "@oclif/core";
export default class Deploy extends Command {
static description = "Deploy the application to an environment";
static examples = [
"<%= config.bin %> deploy production --tag v1.2.3",
"<%= config.bin %> deploy staging --dry-run",
];
static args = {
environment: Args.string({
description: "target environment",
required: true,
options: ["dev", "staging", "prod"],
}),
};
static flags = {
tag: Flags.string({
char: "t",
description: "image tag to deploy",
default: "latest",
}),
"dry-run": Flags.boolean({
description: "show what would be deployed",
default: false,
}),
force: Flags.boolean({
char: "f",
description: "skip confirmation prompts",
default: false,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Deploy);
if (flags["dry-run"]) {
this.log(`Would deploy ${flags.tag} to ${args.environment}`);
return;
}
this.log(`Deploying ${flags.tag} to ${args.environment}...`);
// deployment logic
}
}
oclif's Differentiators
Plugin system. Users can extend your CLI with plugins:
myctl plugins install @myorg/myctl-plugin-monitoring
Auto-update. Built-in support for self-updating via S3, GCS, or npm.
Topic grouping. Commands organized into topics (like heroku apps:create).
Packagers. oclif can produce standalone tarballs, macOS .pkg installers, Windows installers, and Debian packages -- no runtime required.
Strengths: Robust plugin system, auto-update, packaging, scales to large CLIs.
Weaknesses: Heavy scaffolding, slower startup than Commander (more to load), overkill for simple tools.
Clap: Rust CLIs
Clap is the dominant argument parser in the Rust ecosystem. Combined with Rust's performance and single-binary compilation, it's the best choice for CLI tools that need to be fast and widely distributed.
Derive-Based API
// src/main.rs
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "myctl", about = "Manage my infrastructure", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Deploy the application to an environment
Deploy {
/// Target environment
#[arg(value_enum)]
environment: Environment,
/// Image tag to deploy
#[arg(short, long, default_value = "latest")]
tag: String,
/// Show what would be deployed without deploying
#[arg(long)]
dry_run: bool,
/// Skip confirmation prompts
#[arg(short, long)]
force: bool,
},
/// Show deployment status
Status {
/// Filter by environment
#[arg(short, long)]
environment: Option<Environment>,
/// Output as JSON
#[arg(long)]
json: bool,
},
}
#[derive(Clone, ValueEnum)]
enum Environment {
Dev,
Staging,
Prod,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Deploy { environment, tag, dry_run, force } => {
if dry_run {
println!("Would deploy {} to {:?}", tag, environment);
return;
}
println!("Deploying {} to {:?}...", tag, environment);
}
Commands::Status { environment, json } => {
// status logic
}
}
}
Clap's derive macro generates the parser, help text, validation, and shell completions from the struct definition. The compiler catches invalid configurations at build time.
Shell Completions
Clap generates completion scripts for bash, zsh, fish, and PowerShell:
use clap::CommandFactory;
use clap_complete::{generate, shells::Bash};
fn generate_completions() {
let mut cmd = Cli::command();
generate(Bash, &mut cmd, "myctl", &mut std::io::stdout());
}
# Install completions
myctl completions bash > /etc/bash_completion.d/myctl
myctl completions zsh > ~/.zsh/completions/_myctl
Clap strengths: Compile-time validation, excellent performance, derive macro reduces boilerplate, shell completions built-in.
Clap weaknesses: Rust learning curve, slower compile times, more effort to add interactive features.
Click: Python CLIs
Click is the standard Python CLI framework. It uses decorators for a clean, declarative API and handles many edge cases around terminal encoding, piping, and Windows compatibility.
Basic Usage
# myctl.py
import click
@click.group()
@click.version_option()
def cli():
"""Manage my infrastructure."""
pass
@cli.command()
@click.argument("environment", type=click.Choice(["dev", "staging", "prod"]))
@click.option("--tag", "-t", default="latest", help="Image tag to deploy")
@click.option("--dry-run", is_flag=True, help="Show what would be deployed")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompts")
def deploy(environment, tag, dry_run, force):
"""Deploy the application to an environment."""
if dry_run:
click.echo(f"Would deploy {tag} to {environment}")
return
if not force and environment == "prod":
click.confirm(f"Deploy {tag} to production?", abort=True)
click.echo(f"Deploying {tag} to {environment}...")
@cli.command()
@click.option("--environment", "-e", help="Filter by environment")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
def status(environment, as_json):
"""Show deployment status."""
# status logic
pass
if __name__ == "__main__":
cli()
Click strengths: Clean decorator API, excellent documentation, handles terminal edge cases, good testing utilities.
Click weaknesses: Python startup time, distribution is harder than Go/Rust (need Python runtime or PyInstaller).
Cross-Cutting Concerns
Regardless of language, professional CLIs share certain qualities. These patterns apply everywhere.
Colored Output
Color makes output scannable. Use it for status indicators, errors, and emphasis -- but respect the NO_COLOR environment variable and detect non-TTY output.
// TypeScript (chalk)
import chalk from "chalk";
console.log(chalk.green("Success:"), "Deployed to production");
console.log(chalk.red("Error:"), "Connection refused");
console.log(chalk.yellow("Warning:"), "Using default configuration");
// Rust (colored)
use colored::*;
println!("{} Deployed to production", "Success:".green());
println!("{} Connection refused", "Error:".red());
# Python (click's built-in)
click.secho("Success: Deployed to production", fg="green")
click.secho("Error: Connection refused", fg="red", err=True)
Progress Indicators
For operations that take more than a second, show progress:
// TypeScript (ora for spinners)
import ora from "ora";
const spinner = ora("Deploying...").start();
await deploy();
spinner.succeed("Deployed successfully");
// Rust (indicatif for progress bars)
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(total_files);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.unwrap());
for file in files {
upload(file);
pb.inc(1);
}
pb.finish_with_message("Upload complete");
Config Files
CLIs that run frequently should support configuration files so users don't repeat flags every time:
// Look for config in standard locations
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { parse } from "yaml";
function loadConfig(): Config {
const paths = [
join(process.cwd(), ".myctl.yml"), // Project-level
join(process.env.HOME!, ".config/myctl/config.yml"), // User-level
"/etc/myctl/config.yml", // System-level
];
for (const path of paths) {
if (existsSync(path)) {
return parse(readFileSync(path, "utf-8"));
}
}
return {};
}
Follow the XDG Base Directory Specification on Linux (~/.config/toolname/) and platform conventions on macOS and Windows.
Error Handling
Good error messages tell users what went wrong, why, and what to do about it:
# Bad
Error: ECONNREFUSED
# Good
Error: Could not connect to the API at https://api.example.com
This usually means:
- The API server is down
- Your network connection is interrupted
- The URL in ~/.config/myctl/config.yml is incorrect
Run 'myctl doctor' to diagnose connection issues.
Machine-Readable Output
Support --json output for scriptability. Humans read tables; scripts read JSON:
if (options.json) {
console.log(JSON.stringify(data, null, 2));
} else {
printTable(data);
}
This lets users pipe your CLI into jq, combine it with other tools, or use it in automation scripts.
Exit Codes
Use meaningful exit codes. At minimum:
0-- success1-- general error2-- usage error (bad arguments)
Some tools use additional codes (e.g., diff uses 1 for differences found). Document your exit codes if they go beyond the basics.
Distribution
npm (TypeScript/Node)
The simplest distribution for JavaScript tools:
{
"name": "@myorg/myctl",
"bin": { "myctl": "./dist/cli.js" },
"files": ["dist"]
}
npm publish
# Users install with:
npm install -g @myorg/myctl
For standalone binaries without Node.js dependency, use bun build --compile:
bun build ./src/cli.ts --compile --outfile myctl
# Produces a single executable
Cargo (Rust)
cargo publish
# Users install with:
cargo install myctl
For users without Rust, provide prebuilt binaries via GitHub Releases and a shell installer:
curl -fsSL https://myctl.example.com/install.sh | sh
Homebrew
For macOS and Linux distribution, create a Homebrew tap:
# Formula/myctl.rb
class Myctl < Formula
desc "Manage my infrastructure"
homepage "https://github.com/myorg/myctl"
url "https://github.com/myorg/myctl/releases/download/v1.0.0/myctl-1.0.0-darwin-arm64.tar.gz"
sha256 "abc123..."
def install
bin.install "myctl"
end
end
brew tap myorg/tap
brew install myctl
GitHub Releases
The most universal approach. Build binaries for each platform in CI and attach them to a GitHub release:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Upload
uses: softprops/action-gh-release@v2
with:
files: target/${{ matrix.target }}/release/myctl*
The Bottom Line
For simple internal tools in a TypeScript project, Commander.js gets the job done with minimal overhead. For large CLIs with plugin systems and auto-update, oclif provides the infrastructure. For widely distributed tools where startup time and binary distribution matter, Clap with Rust is the best choice. For Python-ecosystem tools, Click is the standard.
Whatever framework you choose, the patterns that make a CLI feel professional are the same: clear help text, colored output that respects NO_COLOR, progress indicators for long operations, JSON output for scriptability, config file support for frequently used options, and error messages that tell users how to fix the problem. These details separate tools people tolerate from tools people recommend.