building-cli-apps
π―Skillfrom mhagrelius/dotfiles
Guides developers in creating robust, Unix-philosophy-aligned command-line interface tools with best practices for argument parsing, stream handling, and cross-language implementation.
Installation
npx skills add https://github.com/mhagrelius/dotfiles --skill building-cli-appsSkill Details
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
Overview
# 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
```dot
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:
```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:
```typescript
// Results to stdout
console.log(JSON.stringify(result));
// Progress to stderr
process.stderr.write('Processing...\n');
```
C#:
```csharp
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
# Python
import sys
sys.exit(0) # Success
sys.exit(1) # Error
```
```typescript
// TypeScript
process.exit(0);
process.exitCode = 1; // Preferred - allows cleanup
```
```csharp
// C#
Environment.Exit(0);
return 1; // From Main
```
3. Configuration Hierarchy
Precedence (highest to lowest):
- CLI arguments (
--config value) - Environment variables (
APP_CONFIG) - Config file (
.apprc,config.json) - Defaults
```python
# 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
β β βββ config.py # mycli config show|set
β βββ lib/ # Shared logic
βββ tests/
βββ commands/
βββ test_process.py
```
Python with typer:
```python
# 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:
```typescript
// 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:
```csharp
var rootCommand = new RootCommand("My CLI");
rootCommand.AddCommand(ProcessCommand.Create());
rootCommand.AddCommand(ConfigCommand.Create());
await rootCommand.InvokeAsync(args);
```
5. Interactive vs Non-Interactive Mode
```python
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):
```python
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...
```
```typescript
import { createInterface } from 'readline';
async function readInput(file: string): Promise
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
```python
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)
```
```typescript
process.on('SIGINT', () => {
console.error('\nInterrupted, cleaning up...');
cleanup();
process.exit(130);
});
```
```csharp
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:
```python
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:
```typescript
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
```python
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:
- What went wrong
- Why it's a problem
- How to fix it
```python
# 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 |
More from this repository8
Modernizes .NET development with C# 14 best practices, minimal APIs, modular architecture, and advanced infrastructure patterns.
Helps debug and configure .NET Aspire distributed applications, resolving service discovery, environment, deployment, and polyglot integration challenges.
Builds interactive terminal user interfaces (TUIs) with responsive layouts, keyboard navigation, and real-time data updates across multiple programming languages.
Transcribes YouTube video content, extracting spoken text from videos for quick understanding and analysis.
Enables precise Python code navigation and type checking using pyright-lsp, finding references, definitions, and verifying type correctness semantically.
Helps developers architect and debug GTK 4/libadwaita applications by addressing lifecycle, threading, resource management, and packaging challenges.
Enables semantic code intelligence for TypeScript/JavaScript, providing accurate references, type checking, definition navigation, and symbol understanding.
Designs GNOME user interfaces with precision, ensuring Human Interface Guidelines compliance and modern libadwaita styling.