GitHub - rutkat/cli-samples: Developer guide to build your own command-line tools. Instructions include React Ink, Rust, Go, Python, and Typescript.

Modern CLI Tool Development Blueprint

A comprehensive guide for building command-line tools compatible with Unix and macOS terminals.


Table of Contents

  1. Architecture & Design Principles
  2. Programming Language Comparison
  3. GitHub Integration
  4. Internationalization (i18n)
  5. Unix/macOS Compatibility
  6. Recommended Libraries by Language
  7. 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


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 --help

Key Features:

  • clap derive macros for argument parsing
  • colored crate 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 --help

Key Features:

  • cobra for subcommands
  • fatih/color for 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:

  • commander for argument parsing
  • chalk for 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:

  • click for argument parsing (decorator-based)
  • rich for colored/formatted output
  • Type hints throughout
  • Entry point in setup.py for hello command
# 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:

  • ink for React-based terminal rendering
  • meow for 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