Modern CLI Tool Development Blueprint
A comprehensive guide for building command-line tools compatible with Unix and macOS terminals.
Table of Contents
- Architecture & Design Principles
- Programming Language Comparison
- GitHub Integration
- Internationalization (i18n)
- Unix/macOS Compatibility
- Recommended Libraries by Language
- Hello World Samples
1. Architecture & Design Principles
Unix Philosophy Core Principles
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
Modular Architecture Pattern
┌─────────────────────────────────────────────────────┐
│ 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)
Command Structure Patterns
# 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
Configuration Hierarchy (precedence: high to low)
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
Exit Codes Convention
| 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) |
2. Programming Language Comparison
Performance & Characteristics
| 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 |
When to Choose Each Language
Rust
// 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
Go
// 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)
TypeScript/Node.js
// 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
Python
# 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.""" pass
Popular Python CLIs: pip, poetry, black, mypy, aws-cli
Distribution Strategies
| 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 |
3. GitHub Integration
Authentication Methods
Method 1: Personal Access Token (PAT)
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
Method 2: OAuth Device Flow (Recommended for distributed CLIs)
┌─────────────┐ 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();
Method 3: GitHub App (Production/Enterprise)
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
API Interaction Patterns
// 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, });
Secure Token Storage Implementation
// 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() }
4. Internationalization (i18n)
Architecture
┌─────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────┤
│ i18n Library │
│ ┌─────────────────────────────────────────────┐ │
│ │ t("messages.welcome", { name: "User" }) │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Translation Files │
│ ├── locales/en.json │
│ ├── locales/es.json │
│ ├── locales/ja.json │
│ └── locales/zh-CN.json │
└─────────────────────────────────────────────────────┘
Translation File Formats
JSON Format (Most Common)
// 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" } } }
Gettext Format (.po files)
# 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"
Language Detection Strategy
// 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'; }
Pluralization Rules
// 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; }, };
Unicode & Terminal Considerations
# 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
i18n Libraries by Language
| 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 |
5. Unix/macOS Compatibility
Shell Compatibility Matrix
| 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 |
Shell Completion Generation
// 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()), _ => {} } }
Path Handling
// 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") }
Terminal Output Best Practices
// 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, };
Output Formatting
// 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
Distribution for macOS
# 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
6. Recommended Libraries by Language
Rust Ecosystem
| 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 |
Go Ecosystem
| 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 |
Node.js/TypeScript Ecosystem
| 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 |
Python Ecosystem
| 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 |
Quick Start Templates
Rust (Recommended for new projects)
# 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
// 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 )
Resources
- 12 Factor CLI Apps
- POSIX Argument Syntax
- GitHub CLI Guidelines
- Command Line Interface Guidelines
- Rust CLI Book
7. Hello World Samples
Complete, runnable CLI samples demonstrating modern patterns for each language. Source files are located in the cli-samples/ directory.
Directory Structure
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
Features Demonstrated
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>
Rust Sample (cli-samples/rust-cli/)
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 }, }
Go Sample (cli-samples/go-cli/)
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 }, }
TypeScript/Node.js Sample (cli-samples/typescript-cli/)
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 -- --help
Key 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) => { /* ... */ });
Python Sample (cli-samples/python-cli/)
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 --help
Key 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.""" # Implementation
React Ink Sample (cli-samples/ink-cli/)
Build & 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 Alice
Key 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} />);
Quick Comparison Table
| 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 |
Running All Samples
# 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