A comprehensive guide for building command-line tools compatible with Unix and macOS terminals.
- Architecture & Design Principles
- Programming Language Comparison
- GitHub Integration
- Internationalization (i18n)
- Unix/macOS Compatibility
- Recommended Libraries by Language
- Hello World Samples
1. Do One Thing Well - Each tool should solve a single problem excellently
2. Compose via Pipes - Output should be usable as input to other tools
3. Text Streams - Use universal interfaces (stdin/stdout/stderr)
4. Silence is Golden - No output on success unless explicitly requested
┌─────────────────────────────────────────────────────┐
│ CLI Layer │
│ (Args parsing, help generation, shell completions) │
├─────────────────────────────────────────────────────┤
│ Core Library │
│ (Business logic, independent of CLI framework) │
├─────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Config, logging, HTTP client, i18n) │
└─────────────────────────────────────────────────────┘
Why Library-First?
- Enables programmatic use via API
- Facilitates unit testing
- Allows multiple interfaces (CLI, GUI, library)
# Hierarchical subcommands (recommended)
myapp resource action [flags] [arguments]
# Examples:
git remote add origin https://github.com/user/repo
docker container run -d nginx
kubectl get pods -n production
1. Command-line flags (--config-value=foo)
2. Environment variables (MYAPP_CONFIG_VALUE=foo)
3. Project config file (./myapp.yaml)
4. User config file (~/.config/myapp/config.yaml)
5. System config file (/etc/myapp/config.yaml)
6. Built-in defaults
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of command (invalid args) |
| 64-78 | BSD/POSIX reserved codes |
| 126 | Command not executable |
| 127 | Command not found |
| 130 | Interrupted (Ctrl+C) |
| Language | Compilation | Startup Time | Runtime Perf | Binary Size | Memory Safety |
|---|---|---|---|---|---|
| Rust | Native | ~1ms | Excellent | Small-Medium | Yes (compile-time) |
| Go | Native | ~1ms | Very Good | Medium-Large | Yes (GC) |
| Java | JVM | ~100ms | Good | Requires JVM | Yes (GC) |
| Node.js | JIT | ~50ms | Moderate | Requires runtime | No |
| Python | Interpreted | ~20ms | Slow | Requires runtime | No |
// Best for: High-performance tools, system utilities, anything critical
// Trade-offs: Steeper learning curve, longer compile times
// Example CLI with clap:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "myapp", about = "A modern CLI tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "List resources")]
List { verbose: bool },
#[command(about = "Create a resource")]
Create { name: String },
}Popular Rust CLIs: ripgrep, fd, bat, exa, starship, alacritty
// Best for: DevOps tools, cloud utilities, rapid development
// Trade-offs: Larger binaries, garbage collection pauses
// Example with cobra:
package cmd
import (
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "A modern CLI tool",
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List resources",
Run: func(cmd *cobra.Command, args []string) {
// Implementation
},
}Popular Go CLIs: docker, kubectl, terraform, hugo, gh (GitHub CLI)
// Best for: Web developers, rapid prototyping, npm ecosystem
// Trade-offs: Requires bundling, slower startup
// Example with oclif:
import { Command, Flags } from '@oclif/core'
export default class List extends Command {
static description = 'List resources'
static flags = {
verbose: Flags.boolean({ char: 'v' }),
}
async run(): Promise<void> {
const { flags } = await this.parse(List)
// Implementation
}
}Popular Node CLIs: npm, yarn, prettier, eslint, tsc, next
# Best for: Quick scripts, data tools, AI/ML integration
# Trade-offs: Slow, requires Python runtime
# Example with click:
import click
@click.group()
def cli():
"""A modern CLI tool."""
pass
@cli.command()
@click.option('--verbose', '-v', is_flag=True)
def list(verbose: bool):
"""List resources."""
passPopular Python CLIs: pip, poetry, black, mypy, aws-cli
| Language | Single Binary | Package Managers | Notes |
|---|---|---|---|
| Rust | Yes | Homebrew, cargo, releases | Static linking by default |
| Go | Yes | Homebrew, go install | Larger binaries |
| Node.js | Bundled (pkg, nexe) | npm, yarn | Or require Node runtime |
| Python | Bundled (PyInstaller) | pip, Homebrew | Or require Python runtime |
Simplest approach for CLI tools
Token Types:
├── Classic PAT (single scope, legacy)
└── Fine-grained PAT (repo-specific, recommended)
Creation URL: https://github.com/settings/tokens
Storage Best Practices:
# Option 1: System keychain (recommended)
# Use platform-specific secure storage:
# - macOS: Keychain
# - Linux: gnome-keyring / kwallet
# - Windows: Credential Manager
# Option 2: Environment variable (CI/CD)
export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
# Option 3: Config file with restricted permissions
~/.config/myapp/credentials # chmod 600┌─────────────┐ 1. Request device code ┌─────────────┐
│ CLI App │ ──────────────────────────────► │ GitHub OAuth│
└─────────────┘ └─────────────┘
│ │
│ 2. Returns: device_code, user_code, │
│ verification_uri, expires_in, interval │
│ │
▼ │
┌─────────────────────────────────────────────────────────────┐
│ CLI displays: │
│ "Visit https://github.com/login/device" │
│ "Enter code: ABCD-EFGH" │
│ "Waiting for authorization..." │
└─────────────────────────────────────────────────────────────┘
│ │
│ 3. Poll for token (every interval seconds) │
│ │
▼ │
┌─────────────┐ 4. Returns access_token ┌─────────────┐
│ CLI App │ ◄────────────────────────────── │ GitHub OAuth│
└─────────────┘ └─────────────┘
Implementation Example (Node.js):
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
const auth = createOAuthDeviceAuth({
clientId: "YOUR_CLIENT_ID",
scopes: ["repo", "read:user"],
onVerification(verification) {
console.log(`Visit: ${verification.verification_uri}`);
console.log(`Enter code: ${verification.user_code}`);
},
});
const { token } = await auth();Best for:
├── Higher rate limits (5,000 req/hour vs 1,000)
├── Fine-grained permissions
├── Organization-wide deployment
└── Audit logging
Flow:
1. Create GitHub App in settings
2. Generate private key
3. Create JWT from private key
4. Exchange JWT for installation token
5. Use installation token for API calls
// Using Octokit (TypeScript/Node.js)
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
// Handle rate limiting
throttle: {
onRateLimit: (retryAfter, options) => {
console.warn(`Rate limited. Retrying after ${retryAfter}s`);
return true; // Auto-retry
},
onAbuseLimit: (retryAfter, options) => {
console.error(`Abuse detected. Waiting ${retryAfter}s`);
return true;
},
},
});
// Example: List repositories
const { data: repos } = await octokit.repos.listForUser({
username: "octocat",
per_page: 100,
});// Rust example using keyring crate
use keyring::Entry;
fn store_token(service: &str, username: &str, token: &str) -> Result<(), Error> {
let entry = Entry::new(service, username)?;
entry.set_password(token)?;
Ok(())
}
fn retrieve_token(service: &str, username: &str) -> Result<String, Error> {
let entry = Entry::new(service, username)?;
entry.get_password()
}┌─────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────┤
│ i18n Library │
│ ┌─────────────────────────────────────────────┐ │
│ │ t("messages.welcome", { name: "User" }) │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Translation Files │
│ ├── locales/en.json │
│ ├── locales/es.json │
│ ├── locales/ja.json │
│ └── locales/zh-CN.json │
└─────────────────────────────────────────────────────┘
// locales/en.json
{
"messages": {
"welcome": "Welcome, {{name}}!",
"error": {
"not_found": "Resource not found",
"permission_denied": "Permission denied"
}
},
"commands": {
"list": {
"description": "List all resources",
"success": "Found {{count}} resource",
"success_plural": "Found {{count}} resources"
}
}
}# locales/es.po
msgid "messages.welcome"
msgstr "Bienvenido, {{name}}!"
msgid "Found {{count}} resource"
msgid_plural "Found {{count}} resources"
msgstr[0] "Se encontró {{count}} recurso"
msgstr[1] "Se encontraron {{count}} recursos"// Priority order for detecting user language
function detectLanguage(): string {
// 1. Explicit flag: --lang es
if (process.env.CLI_LANG) return process.env.CLI_LANG;
// 2. Config file setting
const configLang = readConfig('language');
if (configLang) return configLang;
// 3. Environment variable
if (process.env.LANG) {
return process.env.LANG.split('.')[0].replace('_', '-');
}
// 4. System locale
// macOS/Linux: parse $LANG, $LC_ALL, $LC_MESSAGES
// Windows: Use GetUserDefaultUILanguage()
// 5. Default fallback
return 'en';
}// Different languages have different plural rules
const pluralRules = {
en: (n) => n === 1 ? 0 : 1, // 1 resource, 2 resources
ru: (n) => { // Complex: 1, 2-4, 5+
if (n % 10 === 1 && n % 100 !== 11) return 0;
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 1;
return 2;
},
ja: (n) => 0, // No plural distinction
ar: (n) => { // 6 plural forms
if (n === 0) return 0;
if (n === 1) return 1;
if (n === 2) return 2;
if (n % 100 >= 3 && n % 100 <= 10) return 3;
if (n % 100 >= 11 && n % 100 <= 99) return 4;
return 5;
},
};# Terminal width detection (multi-byte aware)
# Use Unicode-aware string width calculation
# Problem: Japanese characters are "wide"
echo "日本語" # 3 characters, but 6 columns wide
# Solution: Use unicode-width library
# Rust: unicode_width crate
# Node.js: string-width package
# Python: wcwidth library| Language | Library | Features |
|---|---|---|
| Rust | rust-i18n, gettext-rs |
Compile-time extraction, .po support |
| Go | go-i18n |
JSON/YAML/PO, plural rules |
| Node.js | i18next, oclif-i18n |
JSON, interpolation, plurals |
| Python | gettext, babel |
Standard library, .po files |
| Feature | bash | zsh | fish | POSIX sh |
|---|---|---|---|---|
| Default macOS | No (pre-3.0) | Yes (Catalina+) | No | No |
| Array syntax | (${a}) |
(${a}) |
($a) |
Limited |
| Completion | bash-completion | Built-in | Built-in | External |
| Config file | ~/.bashrc |
~/.zshrc |
~/.config/fish/ |
~/.profile |
// Generate completions for multiple shells (Rust + clap)
use clap::{Command, CommandFactory};
use clap_complete::{generate, shells::{Bash, Zsh, Fish, Elvish}};
fn print_completions(shell: &str) {
let mut cmd = Cli::command();
match shell {
"bash" => generate(Bash, &mut cmd, "myapp", &mut std::io::stdout()),
"zsh" => generate(Zsh, &mut cmd, "myapp", &mut std::io::stdout()),
"fish" => generate(Fish, &mut cmd, "myapp", &mut std::io::stdout()),
_ => {}
}
}// Cross-platform path handling
use std::path::PathBuf;
// User's home directory
fn home_dir() -> PathBuf {
// macOS/Linux: $HOME
// Windows: %USERPROFILE%
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
// Config directory
fn config_dir() -> PathBuf {
// macOS: ~/Library/Application Support/myapp
// Linux: ~/.config/myapp (XDG)
// Windows: %APPDATA%/myapp
dirs::config_dir()
.unwrap_or_else(home_dir)
.join("myapp")
}// Respect user preferences
const shouldUseColor =
process.env.NO_COLOR === undefined && // Respect NO_COLOR
process.stdout.isTTY && // Only if terminal
!process.env.CI; // Not in CI
const shouldUsePager =
process.stdout.isTTY &&
outputLines > terminalHeight;
// Terminal detection
const terminalInfo = {
isTTY: process.stdout.isTTY,
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
supportsColor: shouldUseColor,
supportsUnicode: process.env.LC_ALL?.includes('UTF') ?? true,
};// Structured output modes
enum OutputFormat {
Text, // Human-readable (default)
Json, // Machine-readable: --output json
Yaml, // Alternative format: --output yaml
Quiet, // Minimal: -q
}
// Progress indicators
// Use spinners for indeterminate progress
// Use progress bars for determinate progress
// Always disable in non-TTY environments# Homebrew Formula (myapp.rb)
class Myapp < Formula
desc "A modern CLI tool"
homepage "https://github.com/user/myapp"
version "1.0.0"
on_macos do
if Hardware::CPU.intel?
url "https://github.com/user/myapp/releases/download/v1.0.0/myapp-darwin-amd64"
sha256 "..."
else
url "https://github.com/user/myapp/releases/download/v1.0.0/myapp-darwin-arm64"
sha256 "..."
end
end
def install
bin.install "myapp"
generate_completions_from_executable(bin/"myapp", "completion")
end
end| Category | Library | Description |
|---|---|---|
| CLI Parser | clap |
Feature-rich, derive macros |
| Colors | colored, termcolor |
Terminal colors |
| Progress | indicatif |
Progress bars, spinners |
| Errors | miette, anyhow |
Beautiful error reports |
| Config | config-rs |
Multi-format config |
| HTTP | reqwest, ureq |
HTTP client |
| i18n | rust-i18n |
Internationalization |
| Keyring | keyring-rs |
Secure credential storage |
| Completions | clap-complete |
Shell completions |
| Category | Library | Description |
|---|---|---|
| CLI Parser | cobra, urfave/cli |
Subcommands, help |
| Colors | fatih/color |
Terminal colors |
| Progress | schollz/progressbar |
Progress bars |
| Errors | pkg/errors |
Error wrapping |
| Config | spf13/viper |
Multi-format config |
| HTTP | net/http |
Standard library |
| i18n | nicksnyder/go-i18n |
Internationalization |
| Keyring | zalando/go-keyring |
Secure credential storage |
| Category | Library | Description |
|---|---|---|
| CLI Framework | oclif, commander, yargs |
Full-featured CLI |
| Colors | chalk, kleur |
Terminal colors |
| Progress | ora, cli-progress |
Spinners, progress |
| Errors | @oclif/errors |
Error handling |
| Config | conf, rc |
Config management |
| HTTP | node-fetch, axios |
HTTP client |
| i18n | i18next |
Internationalization |
| Keyring | keytar |
Secure credential storage |
| Category | Library | Description |
|---|---|---|
| CLI Framework | click, typer, argparse |
Argument parsing |
| Colors | rich, colorama |
Terminal colors |
| Progress | rich, tqdm |
Progress bars |
| Errors | click, rich |
Error formatting |
| Config | pydantic, dynaconf |
Config management |
| HTTP | httpx, requests |
HTTP client |
| i18n | gettext, babel |
Internationalization |
| Keyring | keyring |
Secure credential storage |
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
miette = { version = "5", features = ["fancy"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
dirs = "5"
colored = "2"
indicatif = "0.17"// go.mod
module github.com/user/myapp
require (
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.0
github.com/fatih/color v1.16.0
github.com/schollz/progressbar/v3 v3.14.0
)- 12 Factor CLI Apps
- POSIX Argument Syntax
- GitHub CLI Guidelines
- Command Line Interface Guidelines
- Rust CLI Book
Complete, runnable CLI samples demonstrating modern patterns for each language. Source files are located in the cli-samples/ directory.
cli-samples/
├── rust-cli/
│ ├── Cargo.toml
│ └── src/main.rs
├── go-cli/
│ ├── go.mod
│ └── main.go
├── typescript-cli/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/index.ts
├── python-cli/
├── requirements.txt
├── setup.py
└── hello_cli.py
└── ink-cli/
├── package.json
├── tsconfig.json
└── src/index.tsx
Each sample CLI tool implements the same functionality:
| Command | Description |
|---|---|
hello |
Default greeting |
hello greet [name] |
Greet someone (with --informal option) |
hello bye [name] |
Say goodbye |
hello info |
Print system information |
hello --help |
Show help |
hello --version |
Show version |
Global options: -v, --verbose, -o, --output <file>
Build & Run:
cd cli-samples/rust-cli
cargo build --release
./target/release/hello --helpKey Features:
clapderive macros for argument parsingcoloredcrate for terminal colors- Subcommand enum pattern
- Count flag for verbosity (
-v,-vv)
// Excerpt from src/main.rs
#[derive(Parser)]
#[command(name = "hello")]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Greet { name: String, informal: bool },
Bye { name: String },
Info { all: bool },
}Build & Run:
cd cli-samples/go-cli
go mod tidy
go build -o hello
./hello --helpKey Features:
cobrafor subcommandsfatih/colorfor terminal colors- Persistent flags for global options
- Local flags for command-specific options
// Excerpt from main.go
var rootCmd = &cobra.Command{
Use: "hello",
Short: "A hello world CLI tool",
}
var greetCmd = &cobra.Command{
Use: "greet [name]",
Short: "Greet someone",
Run: func(cmd *cobra.Command, args []string) {
// Implementation
},
}Build & Run:
cd cli-samples/typescript-cli
npm install
npm run build
node dist/index.js --help
# Or run directly in development:
npm run dev -- --helpKey Features:
commanderfor argument parsingchalkfor terminal colors- ESM-style imports with CommonJS output
- Shebang for executable script
// Excerpt from src/index.ts
import { Command } from 'commander';
import chalk from 'chalk';
const program = new Command();
program
.name('hello')
.version('0.1.0')
.option('-v, --verbose', 'enable verbose output');
program
.command('greet [name]')
.option('-i, --informal', 'use informal greeting')
.action((name, options) => { /* ... */ });Install & Run:
cd cli-samples/python-cli
pip install -r requirements.txt
# Run directly:
python hello_cli.py --help
# Or install as package:
pip install -e .
hello --helpKey Features:
clickfor argument parsing (decorator-based)richfor colored/formatted output- Type hints throughout
- Entry point in
setup.pyforhellocommand
# Excerpt from hello_cli.py
import click
from rich.console import Console
@click.group()
@click.version_option(version='0.1.0', prog_name='hello')
def cli():
"""A hello world CLI tool built with Python."""
@cli.command()
@click.argument('name', default='World')
@click.option('-i', '--informal', is_flag=True)
def greet(name: str, informal: bool):
"""Greet someone."""
# ImplementationBuild & Run:
cd cli-samples/ink-cli
npm install
npm run build
node dist/index.js --help
# Or run directly in development:
npm run dev -- greet AliceKey Features:
inkfor React-based terminal renderingmeowfor argument parsing- React components with JSX syntax
- Flexbox layouts using
<Box>component - Colored text using
<Text>component
// Excerpt from src/index.tsx
import React from 'react';
import { render, Box, Text } from 'ink';
const Greet = ({ name, informal }: { name: string; informal: boolean }) => (
<Box>
<Text bold color="green">
{informal ? 'Hey there' : 'Hello'}
</Text>
<Text>, </Text>
<Text bold color="cyan">{name}</Text>
<Text>! 👋</Text>
</Box>
);
const App = ({ command, name, informal, showAll, verbose, output }) => {
// Render based on command
return (
<Box flexDirection="column">
{command === 'greet' && <Greet name={name} informal={informal} />}
{/* ... other commands */}
</Box>
);
};
render(<App {...cliFlags} />);| Feature | Rust | Go | TypeScript | Python | React Ink |
|---|---|---|---|---|---|
| Framework | clap | cobra | commander | click | ink + meow |
| Colors | colored | fatih/color | chalk | rich | built-in |
| Binary Size | ~2MB | ~8MB | N/A | N/A | N/A |
| Startup Time | ~1ms | ~1ms | ~50ms | ~20ms | ~50ms |
| Build Time | Slow | Fast | Fast | N/A | Fast |
| Distribution | Binary | Binary | npm/bundle | pip/wheel | npm/bundle |
# Rust
cd cli-samples/rust-cli && cargo run -- greet Alice
# Go
cd cli-samples/go-cli && go run main.go greet Alice
# TypeScript
cd cli-samples/typescript-cli && npm run dev -- greet Alice
# Python
cd cli-samples/python-cli && python hello_cli.py greet Alice
# React Ink
cd cli-samples/ink-cli && npm run dev -- greet Alice