building-cli-apps

Building CLI Applications

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "building-cli-apps" with this command: npx skills add mhagrelius/dotfiles/mhagrelius-dotfiles-building-cli-apps

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

  1. 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

  1. Configuration Hierarchy

Precedence (highest to lowest):

  • CLI arguments (--config value )

  • Environment variables (APP_CONFIG )

  • Config file (.apprc , config.json )

  • Defaults

Python with typer

import typer import os

def main( config: str = typer.Option( os.environ.get("APP_CONFIG", "default"), "--config", "-c" ) ): pass

  1. 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);

  1. 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 -

  1. 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:

  • What went wrong

  • Why it's a problem

  • 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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

using-typescript-lsp

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

developing-gtk-apps

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

using-python-lsp

No summary provided by upstream source.

Repository SourceNeeds Review
General

dotnet-10-csharp-14

No summary provided by upstream source.

Repository SourceNeeds Review