Loading...
Loading...
Best practices for building CLI applications across languages. Covers CLI design principles (Unix philosophy, command structure, subcommands vs flags), argument parsing (required/optional args, flags, environment variables, config files, precedence), user interface (help text, version info, progress indicators, color output, interactive prompts), output formatting (human-readable vs machine-readable JSON/YAML, exit codes), error handling (clear messages, suggestions, debug mode), cross-platform considerations (paths, line endings, terminal capabilities), testing strategies (integration tests, output verification, exit codes), documentation (README, man pages, built-in help), and language-specific libraries. Activate when working with CLI applications, command-line tools, argument parsing, CLI utilities, argument handling, commands, subcommands, CLI frameworks, or building command-line interfaces.
npx skill4agent add ilude/claude-code-config cli-development# Good: clear action and object
git add <file> # verb: add, noun: file
docker run <image> # verb: run, noun: image
npm install <package> # verb: install, noun: package
# Avoid: unclear structure
git foo # ambiguous
docker something # unclear what happensgit branch # subcommand
git branch --list # subcommand with flag
git branch --delete <name> # subcommand with flag and argumentls --color # flag modifies ls behavior
grep -r --include # flags modify search behavior# Avoid: unclear hierarchy
tool --verbose subcommand --debug --mode strict
# Better: clear structure
tool subcommand --verbose --debug --mode strict# Good: clear, discoverable
aws s3 ls
aws ec2 describe-instances
# Avoid: too deep
cloud provider storage list all bucketscp <source> <destination> # Two required positional arguments
docker run <image> # One required positional argumentnpm install [directory] # Optional, defaults to current directory
grep [options] <pattern> [file] # File argument optionalls -l # long format
grep -r # recursive
tar -xvf # combined flagsdocker run --detach --name myapp
npm install --save-dev
grep --recursive --include="*.js"# Boolean flags
--verbose # boolean true/false
--color=always # explicit value
# Flags with values
--output file.txt # space-separated
--output=file.txt # equals-separated
# Accept both styles when possible
--timeout 30 or --timeout=30export DEBUG=1
export LOG_LEVEL=debug
export API_TOKEN=secret# If user runs with flag, it takes precedence
tool --timeout 10 # Uses 10, ignores TIMEOUT env var
# If no flag, check env var
export TIMEOUT=5
tool # Uses 5 from TIMEOUT
# If no flag or env var, use config file default
tool --config config.yaml # Reads timeout from config.yaml
# If nothing specified, use built-in default
tool # Uses hardcoded default (e.g., 30)~/.config/app/config.yaml~/.apprc%APPDATA%\App\config.yaml~\AppData\Local\App\config.yaml./config.yaml# ~/.config/myapp/config.yaml
debug: false
log_level: info
timeout: 30
output_format: json
color: true
api:
endpoint: https://api.example.com
timeout: 10# Python example
import os
from pathlib import Path
import yaml
def load_config():
# Built-in defaults
config = {
'debug': False,
'timeout': 30,
'color': True
}
# Load from config file
config_path = Path.home() / '.config' / 'myapp' / 'config.yaml'
if config_path.exists():
with open(config_path) as f:
file_config = yaml.safe_load(f)
config.update(file_config)
# Override with environment variables
if os.getenv('DEBUG'):
config['debug'] = os.getenv('DEBUG').lower() == 'true'
if os.getenv('TIMEOUT'):
config['timeout'] = int(os.getenv('TIMEOUT'))
# Note: Command-line args handled by argument parser, applied after
return config$ tool --help
Usage: tool [OPTIONS] COMMAND [ARGS]...
A brief description of what this tool does.
Options:
-v, --verbose Increase output verbosity
-q, --quiet Suppress non-error output
-h, --help Show this message and exit
--version Show version and exit
Commands:
add Add a new item
list List all items
delete Delete an item
config Manage configuration
Examples:
tool add myitem
tool list --format json
tool delete --force
For more information: https://docs.example.comUsage: tool [OPTIONS] COMMAND [ARGS]...$ tool add --help
Usage: tool add [OPTIONS] NAME
Add a new item to the collection.
Options:
--description TEXT Item description
--tags TEXT Comma-separated tags
--priority INT Priority level (1-10)
-h, --help Show this message and exit
Examples:
tool add "My Item"
tool add "Task" --priority 5 --tags work,urgent$ tool --version
tool 1.2.3
# For complex tools, show component versions
$ tool --version
tool 1.2.3
dependency-a: 2.1.0
dependency-b: 5.4.3# __version__.py or __init__.py
__version__ = "1.2.3"
# In main CLI file
import sys
from . import __version__
@click.command()
@click.option('--version', is_flag=True, help='Show version and exit')
def main(version):
if version:
click.echo(f"tool {__version__}")
sys.exit(0)# Python: using rich library
from rich.progress import track
import time
for i in track(range(100), description="Processing..."):
time.sleep(0.1) # Long operationProcessing... [████████░░] 80%
or
Processing... ⠏ (spinning indicator)Connecting to server... ⠋
Uploading file... ⠙
Waiting for response... ⠹Downloading [████████░░░░░░] 40% (2.1 MB / 5.3 MB)
Processing [██████████████████░░] 90%--no-progress# Python: using rich or click
import click
@click.command()
@click.option('--color', type=click.Choice(['always', 'auto', 'never']),
default='auto', help='Color output mode')
def main(color):
# auto: color if terminal supports it
# always: force color (for piping to less -R)
# never: disable color
use_color = color == 'always' or (color == 'auto' and is_terminal())# User can disable via environment
NO_COLOR=1 tool # Disable color
FORCE_COLOR=1 tool # Force colorimport sys
import os
def supports_color():
# Check NO_COLOR env var
if os.getenv('NO_COLOR'):
return False
# Check FORCE_COLOR env var
if os.getenv('FORCE_COLOR'):
return True
# Check if stdout is a TTY
return sys.stdout.isatty()# Python: using click
import click
@click.command()
@click.option('--force', is_flag=True, help='Skip confirmations')
def delete_item(force):
if not force:
if not click.confirm('Are you sure you want to delete?'):
click.echo("Cancelled")
return
# Proceed with deletion
click.echo("Deleted")
# Interactive selection
choice = click.prompt(
'Choose an option',
type=click.Choice(['a', 'b', 'c']),
default='a'
)--force$ tool list
Name Status Modified
────────────────────────────────
Project A Active 2 hours ago
Project B Inactive 3 days ago
Project C Active 1 week ago$ tool list --format json
[
{
"name": "Project A",
"status": "active",
"modified": "2024-01-15T14:30:00Z"
},
{
"name": "Project B",
"status": "inactive",
"modified": "2024-01-12T09:15:00Z"
}
]$ tool list --format yaml
- name: Project A
status: active
modified: 2024-01-15T14:30:00Z
- name: Project B
status: inactive
modified: 2024-01-12T09:15:00Z$ tool list --format csv
name,status,modified
"Project A",active,2024-01-15T14:30:00Z
"Project B",inactive,2024-01-12T09:15:00Zimport click
import json
import csv
import io
@click.command()
@click.option('--format', type=click.Choice(['table', 'json', 'yaml', 'csv']),
default='table', help='Output format')
def list_items(format):
items = get_items()
if format == 'json':
click.echo(json.dumps(items, indent=2))
elif format == 'csv':
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=items[0].keys())
writer.writeheader()
writer.writerows(items)
click.echo(output.getvalue())
elif format == 'yaml':
import yaml
click.echo(yaml.dump(items, default_flow_style=False))
else: # table
display_table(items)0 - Success, no errors
1 - General error
2 - Misuse of shell command (invalid arguments)
3-125 - Application-specific errors
126 - Command cannot execute
127 - Command not found
128+130 - Fatal signal "N"
130 - Script terminated by Ctrl+C0 - Success
1 - General/unspecified error
2 - Misuse of command syntax
64 - Bad input data
65 - Data format error
66 - No input file
69 - Service unavailable
70 - Internal software error
71 - System error
77 - Permission denied
78 - Configuration errorimport click
import sys
@click.command()
def main():
try:
result = do_work()
if not result:
sys.exit(1) # Error condition
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(2) # Bad input
except PermissionError as e:
click.echo(f"Permission denied: {e}", err=True)
sys.exit(77)
except Exception as e:
click.echo(f"Internal error: {e}", err=True)
sys.exit(70)
sys.exit(0) # Success# Good: script can detect success/failure
tool && echo "Success" || echo "Failed"
# Bad: unclear exit status
tool
echo "Done" # Prints regardless of success# Good
$ tool add --priority invalid
Error: --priority must be a number (1-10), got 'invalid'
# Bad
$ tool add --priority invalid
Error: Invalid argument
# Good
$ tool delete nonexistent
Error: Item 'nonexistent' not found. Use 'tool list' to see available items.
# Bad
$ tool delete nonexistent
File not foundError: [what happened]. [How to fix it or where to learn more].import click
@click.command()
@click.argument('command')
def main(command):
valid_commands = ['add', 'list', 'delete']
if command not in valid_commands:
# Suggest closest match
from difflib import get_close_matches
suggestions = get_close_matches(command, valid_commands, n=1)
msg = f"Unknown command '{command}'."
if suggestions:
msg += f" Did you mean '{suggestions[0]}'?"
else:
msg += f" Available commands: {', '.join(valid_commands)}"
click.echo(f"Error: {msg}", err=True)
raise SystemExit(1)import click
import sys
import traceback
@click.command()
@click.option('--debug', is_flag=True, help='Show detailed error information')
def main(debug):
try:
do_work()
except Exception as e:
if debug:
# Show full traceback
traceback.print_exc(file=sys.stderr)
else:
# Show user-friendly message
click.echo(f"Error: {str(e)}", err=True)
click.echo("Use --debug to see details", err=True)
sys.exit(1)$ tool --debug
Error: Connection failed
Traceback (most recent call last):
File "tool.py", line 42, in main
connect_to_server()
File "tool.py", line 15, in connect_to_server
raise ConnectionError("Timeout after 30s")
ConnectionError: Timeout after 30s# Good: use pathlib (cross-platform)
from pathlib import Path
config_path = Path.home() / '.config' / 'app' / 'config.yaml'
output_path = Path('output') / 'result.txt'
# Also good: use os.path
import os
config_path = os.path.join(os.path.expanduser('~'), '.config', 'app', 'config.yaml')
# Avoid: hardcoded separators
config_path = '~/.config/app/config.yaml' # Wrong on Windowsconst path = require('path');
const os = require('os');
const configPath = path.join(os.homedir(), '.config', 'app', 'config.yaml');
const outputPath = path.join('output', 'result.txt');# Python: open files with universal newline support (default)
with open('file.txt', 'r') as f: # Automatically handles \r\n, \n, \r
content = f.read()
# When writing, use default newline handling
with open('file.txt', 'w') as f:
f.write(content) # Uses platform default newline# Set in .gitattributes to normalize line endings
* text=auto
*.py text eol=lf
*.json text eol=lfimport sys
import os
def get_terminal_width():
"""Get terminal width, default to 80."""
try:
return os.get_terminal_size().columns
except (AttributeError, ValueError):
return 80
def supports_unicode():
"""Check if terminal supports Unicode."""
encoding = sys.stdout.encoding or 'utf-8'
return encoding.lower() in ('utf-8', 'utf8')
def supports_color():
"""Check if terminal supports colors."""
# Already covered above
return True
# Use capabilities to adjust output
if supports_unicode():
symbol = '✓' # Check mark
else:
symbol = '✔' # Alternative# Bad: assumes capabilities
output = "✓ Success\n"
# Good: checks capabilities
symbol = '✓' if supports_unicode() else '✔'
output = f"{symbol} Success\n"import subprocess
import json
def test_list_command():
"""Test list command output."""
result = subprocess.run(
['tool', 'list', '--format', 'json'],
capture_output=True,
text=True
)
assert result.returncode == 0
output = json.loads(result.stdout)
assert isinstance(output, list)
def test_list_command_human_readable():
"""Test list command with default format."""
result = subprocess.run(
['tool', 'list'],
capture_output=True,
text=True
)
assert result.returncode == 0
assert 'Name' in result.stdout # Table headerfrom click.testing import CliRunner
from tool.cli import main
def test_list_command():
runner = CliRunner()
result = runner.invoke(main, ['list', '--format', 'json'])
assert result.exit_code == 0
output = json.loads(result.output)
assert isinstance(output, list)import subprocess
def test_add_command_success():
"""Test successful add operation."""
result = subprocess.run(
['tool', 'add', 'Test Item', '--priority', '5'],
capture_output=True,
text=True
)
assert result.returncode == 0
assert 'Added' in result.stdout or 'Success' in result.stdout
def test_add_command_invalid_priority():
"""Test validation of priority argument."""
result = subprocess.run(
['tool', 'add', 'Test Item', '--priority', 'invalid'],
capture_output=True,
text=True
)
assert result.returncode != 0
assert 'Error' in result.stderr
assert 'priority' in result.stderr.lower()import subprocess
def test_help_flag_exit_code():
"""Test --help returns success."""
result = subprocess.run(['tool', '--help'], capture_output=True)
assert result.returncode == 0
def test_invalid_command_exit_code():
"""Test invalid command returns error code."""
result = subprocess.run(['tool', 'invalid'], capture_output=True)
assert result.returncode != 0
def test_missing_required_arg():
"""Test missing required argument returns error."""
result = subprocess.run(['tool', 'delete'], capture_output=True)
assert result.returncode == 2 # Misuse of command syntax# Tool Name
Brief description of what the tool does.
## Installation
Installation instructions (package manager, build from source, etc.)
## Usage
### Basic Usage
```bash
tool [COMMAND] [OPTIONS] [ARGUMENTS]# List all items
tool list
# Add new item
tool add "My Item" --priority 5
# Delete with confirmation
tool delete "My Item"
# Suppress confirmation
tool delete "My Item" --forceadd [NAME]listdelete [NAME]config-v, --verbose--format [json|yaml|csv]--debug-h, --help--version~/.config/tool/config.yamlDEBUGTOOL_CONFIGNO_COLOR~/.config/tool/config.yamldebug: false
format: table
color: truewhich toolchmod +x tool
### Man Pages
**Create man pages for Unix systems:**
List items as JSON:
tool list --format json
### Built-in Help
**Make help accessible and comprehensive:**
```python
@click.group()
def main():
"""Main CLI tool."""
pass
@main.command()
def help():
"""Show detailed help information."""
click.echo(click.get_current_context().get_help())import click
@click.command()
@click.option('--name', prompt='Your name', help='Name of person')
@click.option('--count', default=1, help='Number of greetings')
def hello(name, count):
"""Simple program that greets NAME COUNT times."""
for _ in range(count):
click.echo(f'Hello {name}!')
if __name__ == '__main__':
hello()import typer
app = typer.Typer()
@app.command()
def add(name: str, priority: int = typer.Option(5, min=1, max=10)):
"""Add a new item."""
print(f"Added {name} with priority {priority}")
if __name__ == "__main__":
app()import argparse
parser = argparse.ArgumentParser(description='Process some integers')
parser.add_argument('--name', required=True, help='Name')
parser.add_argument('--count', type=int, default=1, help='Count')
args = parser.parse_args()const { Command } = require('commander');
const program = new Command();
program
.command('add <name>')
.option('--priority <number>', 'Item priority', '5')
.action((name, options) => {
console.log(`Added ${name} with priority ${options.priority}`);
});
program.parse(process.argv);const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
yargs(hideBin(process.argv))
.command('add <name>', 'Add item', (yargs) => {
return yargs.option('priority', { type: 'number', default: 5 });
}, (argv) => {
console.log(`Added ${argv.name} with priority ${argv.priority}`);
})
.argv;const {Command, flags} = require('@oclif/command');
class AddCommand extends Command {
static description = 'Add a new item';
static args = [{ name: 'name' }];
static flags = {
priority: flags.integer({ default: 5 })
};
async run() {
const { args, flags } = this.parse(AddCommand);
this.log(`Added ${args.name} with priority ${flags.priority}`);
}
}
module.exports = AddCommand;var rootCmd = &cobra.Command{
Use: "tool",
Short: "A brief description",
Long: "A longer description...",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Hello World!")
},
}
var addCmd = &cobra.Command{
Use: "add [name]",
Short: "Add a new item",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Added %s\n", args[0])
},
}
func init() {
rootCmd.AddCommand(addCmd)
addCmd.Flags().IntP("priority", "p", 5, "Priority level")
}use clap::{Parser, Subcommand};
#[derive(Parser)]
#[clap(name = "tool")]
#[clap(about = "A tool that does one thing well")]
struct Cli {
#[clap(subcommand)]
command: Option<Commands>,
#[clap(short, long)]
debug: bool,
}
#[derive(Subcommand)]
enum Commands {
Add {
name: String,
#[clap(short, long, default_value = "5")]
priority: u8,
},
List,
}
fn main() {
let cli = Cli::parse();
// Handle commands...
}# Global options apply to tool
tool --verbose add item
# Subcommand options apply to subcommand
tool add item --priority 5
# Mix of both
tool --verbose add item --priority 5# Pipe output to other tools
tool list --format json | jq '.[] | select(.status == "active")'
# Use as input to other commands
tool export > backup.json
tool import < backup.json
# Chain commands
tool list | grep important | while read item; do tool process "$item"; doneimport sys
import click
@click.command()
@click.option('--force', is_flag=True, help='Skip confirmations')
def delete_item(force):
if sys.stdin.isatty() and not force:
# Interactive - prompt for confirmation
if not click.confirm('Delete this item?'):
click.echo('Cancelled')
return
# Non-interactive or confirmed
perform_deletion()import signal
import sys
def timeout_handler(signum, frame):
print("Operation timed out")
sys.exit(124)
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(30) # 30 second timeout
try:
result = long_running_operation()
finally:
signal.alarm(0) # Cancel alarm.claude/CLAUDE.md