Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add mhagrelius/dotfiles --skill "building-cli-apps"
Install specific skill from multi-skill repository
# Description
Use when building command-line interface tools; when choosing argument parsing libraries; when handling stdin/stdout/stderr patterns; when implementing subcommands; when tests for CLI apps fail or are missing
# SKILL.md
name: building-cli-apps
description: Use when building command-line interface tools; when choosing argument parsing libraries; when handling stdin/stdout/stderr patterns; when implementing subcommands; when tests for CLI apps fail or are missing
Building CLI Applications
Overview
CLI apps are filters in a pipeline. They read input, transform it, write output. The Unix philosophy applies: do one thing well, compose with others.
When to Use CLI vs TUI vs GUI
digraph decision {
rankdir=TB;
"User interaction needed?" [shape=diamond];
"Complex state/navigation?" [shape=diamond];
"Scriptable/automatable?" [shape=diamond];
"CLI" [shape=box, style=filled, fillcolor=lightblue];
"TUI" [shape=box, style=filled, fillcolor=lightgreen];
"GUI" [shape=box, style=filled, fillcolor=lightyellow];
"User interaction needed?" -> "Complex state/navigation?" [label="yes"];
"User interaction needed?" -> "Scriptable/automatable?" [label="no"];
"Scriptable/automatable?" -> "CLI" [label="yes"];
"Scriptable/automatable?" -> "GUI" [label="no"];
"Complex state/navigation?" -> "TUI" [label="yes"];
"Complex state/navigation?" -> "CLI" [label="no"];
}
Choose CLI when: Single operation, pipeable, scriptable, CI/CD, simple prompts
Choose TUI when: Dashboard, multi-view navigation, real-time monitoring
Choose GUI when: Non-technical users, complex visualizations, drag/drop
Quick Reference: Libraries by Language
| Language | Argument Parsing | Progress/Spinners | Colors | Prompts |
|---|---|---|---|---|
| Python | typer (modern) or click |
rich.progress |
rich |
rich.prompt |
| TypeScript | commander or yargs |
ora |
chalk |
inquirer |
| C# | System.CommandLine |
Spectre.Console |
Spectre.Console |
Spectre.Console |
Core Patterns
1. Streams: stdout vs stderr
stdout β Data/results (pipeable)
stderr β Progress, logs, errors (human feedback)
Python:
import sys
from rich.console import Console
console = Console(stderr=True) # Progress/logs to stderr
output = Console() # Results to stdout
console.print("[dim]Processing...[/]") # β stderr
output.print_json(data=result) # β stdout (pipeable)
TypeScript:
// Results to stdout
console.log(JSON.stringify(result));
// Progress to stderr
process.stderr.write('Processing...\n');
C#:
Console.WriteLine(result); // stdout
Console.Error.WriteLine("Working..."); // stderr
2. Exit Codes
| Code | Meaning | Use When |
|---|---|---|
| 0 | Success | Operation completed |
| 1 | General error | User/input errors |
| 2 | Misuse | Invalid arguments |
| 130 | SIGINT | Ctrl+C interrupted |
# Python
import sys
sys.exit(0) # Success
sys.exit(1) # Error
// TypeScript
process.exit(0);
process.exitCode = 1; // Preferred - allows cleanup
// C#
Environment.Exit(0);
return 1; // From Main
3. Configuration Hierarchy
Precedence (highest to lowest):
1. CLI arguments (--config value)
2. Environment variables (APP_CONFIG)
3. Config file (.apprc, config.json)
4. Defaults
# Python with typer
import typer
import os
def main(
config: str = typer.Option(
os.environ.get("APP_CONFIG", "default"),
"--config", "-c"
)
):
pass
4. Subcommand Structure
mycli/
βββ src/
β βββ main.py # Entry point, registers commands
β βββ commands/
β β βββ __init__.py
β β βββ process.py # mycli process <file>
β β βββ config.py # mycli config show|set
β βββ lib/ # Shared logic
βββ tests/
βββ commands/
βββ test_process.py
Python with typer:
# main.py
import typer
from commands import process, config
app = typer.Typer()
app.add_typer(process.app, name="process")
app.add_typer(config.app, name="config")
if __name__ == "__main__":
app()
TypeScript with commander:
// index.ts
import { Command } from 'commander';
import { processCommand } from './commands/process';
import { configCommand } from './commands/config';
const program = new Command();
program.addCommand(processCommand);
program.addCommand(configCommand);
program.parse();
C# with System.CommandLine:
var rootCommand = new RootCommand("My CLI");
rootCommand.AddCommand(ProcessCommand.Create());
rootCommand.AddCommand(ConfigCommand.Create());
await rootCommand.InvokeAsync(args);
5. Interactive vs Non-Interactive Mode
import sys
import typer
from rich.prompt import Confirm
def main(
force: bool = typer.Option(False, "--force", "-f"),
file: str = typer.Argument(...)
):
# Check if running interactively
is_interactive = sys.stdin.isatty()
if not force and is_interactive:
if not Confirm.ask(f"Delete {file}?"):
raise typer.Abort()
elif not force and not is_interactive:
# Non-interactive without --force: fail safe
typer.echo("Use --force in non-interactive mode", err=True)
raise typer.Exit(1)
# Proceed with operation
delete_file(file)
6. Reading from stdin (Piped Input)
Support both file arguments and piped input (- convention):
import sys
import typer
@app.command()
def process(
file: str = typer.Argument(..., help="Input file (or - for stdin)")
):
if file == "-":
content = sys.stdin.read()
else:
content = Path(file).read_text()
# Process content...
import { createInterface } from 'readline';
async function readInput(file: string): Promise<string> {
if (file === '-') {
const lines: string[] = [];
const rl = createInterface({ input: process.stdin });
for await (const line of rl) lines.push(line);
return lines.join('\n');
}
return fs.readFileSync(file, 'utf-8');
}
Usage: cat data.txt | mycli process - or echo "test" | mycli process -
7. Signal Handling
import signal
import sys
def handle_sigint(signum, frame):
print("\nInterrupted, cleaning up...", file=sys.stderr)
cleanup()
sys.exit(130)
signal.signal(signal.SIGINT, handle_sigint)
process.on('SIGINT', () => {
console.error('\nInterrupted, cleaning up...');
cleanup();
process.exit(130);
});
Console.CancelKeyPress += (sender, e) => {
e.Cancel = true; // Prevent immediate termination
Console.Error.WriteLine("\nInterrupted, cleaning up...");
Cleanup();
Environment.Exit(130);
};
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Progress to stdout | Breaks piping | Use stderr |
| Silent failures | User doesn't know what failed | Print error + exit non-zero |
No --help |
Unusable | Use typer/commander (auto-generates) |
| Hardcoded paths | Not portable | Use env vars or config |
| No exit codes | Scripts can't check success | Exit 0/1 appropriately |
| Require confirmation in pipes | Hangs automation | Check isatty(), use --force |
| Catching all exceptions | Hides bugs | Catch specific, let others crash |
Testing CLI Apps
Python with pytest:
from typer.testing import CliRunner
from myapp.main import app
runner = CliRunner()
def test_process_success():
result = runner.invoke(app, ["process", "test.txt"])
assert result.exit_code == 0
assert "processed" in result.stdout
def test_process_missing_file():
result = runner.invoke(app, ["process", "nonexistent.txt"])
assert result.exit_code == 1
assert "not found" in result.stderr
def test_piped_input(tmp_path):
input_file = tmp_path / "input.txt"
input_file.write_text("test data")
result = runner.invoke(app, ["process", "-"], input="test data")
assert result.exit_code == 0
TypeScript with Jest:
import { execSync } from 'child_process';
test('process command succeeds', () => {
const result = execSync('npx ts-node src/index.ts process test.txt');
expect(result.toString()).toContain('processed');
});
test('process command fails on missing file', () => {
expect(() => {
execSync('npx ts-node src/index.ts process nonexistent.txt');
}).toThrow();
});
Help Text Best Practices
import typer
app = typer.Typer(
help="Process loan notices with AI classification.",
no_args_is_help=True, # Show help if no args
)
@app.command()
def process(
file: str = typer.Argument(..., help="Path to notice file (or - for stdin)"),
output: str = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"),
format: str = typer.Option("json", "--format", "-f", help="Output format: json, csv, table"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show processing details"),
):
"""
Process a loan notice through the classification pipeline.
Examples:
mycli process notice.pdf
mycli process notice.pdf --format table
cat notice.txt | mycli process - --output result.json
"""
pass
Error Messages
Good error messages include:
1. What went wrong
2. Why it's a problem
3. How to fix it
# Bad
print("Error: invalid input")
sys.exit(1)
# Good
print(f"Error: File '{path}' is not a valid PDF.", file=sys.stderr)
print(f"Expected: PDF file with loan notice content", file=sys.stderr)
print(f"Try: mycli process --help for supported formats", file=sys.stderr)
sys.exit(1)
Distribution
| Language | Method | Command |
|---|---|---|
| Python | PyPI | pip install myapp or pipx install myapp |
| Python | Single file | pyinstaller --onefile main.py |
| TypeScript | npm | npm install -g myapp |
| TypeScript | Binary | pkg . or bun build --compile |
| C# | NuGet tool | dotnet tool install -g myapp |
| C# | Single file | dotnet publish -c Release -p:PublishSingleFile=true |
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.