building-cli-apps

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Building CLI Applications

构建CLI应用

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.
CLI应用是管道中的过滤器。它们读取输入、转换数据、输出结果。遵循Unix哲学:专注做好一件事,与其他工具组合使用。

When to Use CLI vs TUI vs GUI

CLI、TUI与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
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"];
}
选择CLI的场景: 单一操作、支持管道、可脚本化、CI/CD环境、简单交互提示 选择TUI的场景: 仪表盘、多视图导航、实时监控 选择GUI的场景: 非技术用户、复杂可视化、拖拽操作

Quick Reference: Libraries by Language

按语言分类的工具库速查

LanguageArgument ParsingProgress/SpinnersColorsPrompts
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
语言参数解析进度/加载动画颜色输出交互提示
Python
typer
(现代版)或
click
rich.progress
rich
rich.prompt
TypeScript
commander
yargs
ora
chalk
inquirer
C#
System.CommandLine
Spectre.Console
Spectre.Console
Spectre.Console

Core Patterns

核心模式

1. Streams: stdout vs stderr

1. 流处理: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
stdout → 数据/结果(支持管道传递)
stderr → 进度、日志、错误信息(面向人类的反馈)
Python示例:
python
import sys
from rich.console import Console

console = Console(stderr=True)  # 进度/日志输出到stderr
output = Console()              # 结果输出到stdout

console.print("[dim]Processing...[/]")  # → stderr
output.print_json(data=result)          # → stdout(可管道传递)
TypeScript示例:
typescript
// 结果输出到stdout
console.log(JSON.stringify(result));

// 进度输出到stderr
process.stderr.write('Processing...\n');
C#示例:
csharp
Console.WriteLine(result);           // stdout
Console.Error.WriteLine("Working..."); // stderr

2. Exit Codes

2. 退出码

CodeMeaningUse When
0SuccessOperation completed
1General errorUser/input errors
2MisuseInvalid arguments
130SIGINTCtrl+C interrupted
python
undefined
代码含义使用场景
0成功操作完成
1通用错误用户/输入错误
2使用不当参数无效
130SIGINTCtrl+C中断
python
undefined

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
import sys sys.exit(0) # 成功 sys.exit(1) # 错误

```typescript
// TypeScript
process.exit(0);
process.exitCode = 1;  // 推荐方式 - 允许执行清理操作
csharp
// C#
Environment.Exit(0);
return 1;  // 从Main方法返回

3. Configuration Hierarchy

3. 配置优先级

Precedence (highest to lowest):
  1. CLI arguments (
    --config value
    )
  2. Environment variables (
    APP_CONFIG
    )
  3. Config file (
    .apprc
    ,
    config.json
    )
  4. Defaults
python
undefined
优先级(从高到低):
  1. CLI参数(
    --config value
  2. 环境变量(
    APP_CONFIG
  3. 配置文件(
    .apprc
    ,
    config.json
  4. 默认值
python
undefined

Python with typer

Python + typer

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

4. Subcommand Structure

4. 子命令结构

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:
python
undefined
mycli/
├── src/
│   ├── main.py          # 入口文件,注册命令
│   ├── commands/
│   │   ├── __init__.py
│   │   ├── process.py   # mycli process <file>
│   │   └── config.py    # mycli config show|set
│   └── lib/             # 共享逻辑
└── tests/
    └── commands/
        └── test_process.py
Python + typer示例:
python
undefined

main.py

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);
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 + 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# + 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

5. 交互式与非交互式模式

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)
python
import sys
import typer
from rich.prompt import Confirm

def main(
    force: bool = typer.Option(False, "--force", "-f"),
    file: str = typer.Argument(...)
):
    # 检查是否处于交互式环境
    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:
        # 非交互式环境未加--force参数:安全终止
        typer.echo("Use --force in non-interactive mode", err=True)
        raise typer.Exit(1)

    # 执行操作
    delete_file(file)

6. Reading from stdin (Piped Input)

6. 从stdin读取管道输入

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<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 -
支持文件参数和管道输入(遵循
-
约定):
python
import sys
import typer
from pathlib import Path

@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()
    # 处理内容...
typescript
import { createInterface } from 'readline';
import fs from 'fs';

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');
}
使用示例:
cat data.txt | mycli process -
echo "test" | mycli process -

7. Signal Handling

7. 信号处理

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);
};
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;  // 阻止立即终止
    Console.Error.WriteLine("\nInterrupted, cleaning up...");
    Cleanup();
    Environment.Exit(130);
};

Anti-Patterns

反模式

Anti-PatternProblemFix
Progress to stdoutBreaks pipingUse stderr
Silent failuresUser doesn't know what failedPrint error + exit non-zero
No
--help
UnusableUse typer/commander (auto-generates)
Hardcoded pathsNot portableUse env vars or config
No exit codesScripts can't check successExit 0/1 appropriately
Require confirmation in pipesHangs automationCheck
isatty()
, use
--force
Catching all exceptionsHides bugsCatch specific, let others crash
反模式问题修复方案
进度输出到stdout破坏管道传递使用stderr输出
静默失败用户无法得知错误原因打印错误信息 + 非零码退出
--help
命令
工具不可用使用typer/commander(自动生成帮助)
硬编码路径不具备可移植性使用环境变量或配置文件
无退出码脚本无法判断操作是否成功合理返回0/1退出码
管道模式下要求确认导致自动化流程挂起检查
isatty()
,使用
--force
参数
捕获所有异常隐藏bug只捕获特定异常,让其他异常暴露

Testing CLI Apps

CLI应用测试

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();
});
Python + 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 + 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
python
import typer

app = typer.Typer(
    help="通过AI分类处理贷款通知。",
    no_args_is_help=True,  # 无参数时显示帮助
)

@app.command()
def process(
    file: str = typer.Argument(..., help="通知文件路径(或-表示从stdin读取)"),
    output: str = typer.Option(None, "--output", "-o", help="输出文件(默认:stdout)"),
    format: str = typer.Option("json", "--format", "-f", help="输出格式:json, csv, table"),
    verbose: bool = typer.Option(False, "--verbose", "-v", help="显示处理详情"),
):
    """
    通过分类流水线处理贷款通知。

    示例:
        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
python
undefined
优质错误消息应包含:
  1. 错误内容
  2. 错误原因
  3. 修复方法
python
undefined

Bad

不良示例

print("Error: invalid input") sys.exit(1)
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)
undefined
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)
undefined

Distribution

分发方式

LanguageMethodCommand
PythonPyPI
pip install myapp
or
pipx install myapp
PythonSingle file
pyinstaller --onefile main.py
TypeScriptnpm
npm install -g myapp
TypeScriptBinary
pkg .
or
bun build --compile
C#NuGet tool
dotnet tool install -g myapp
C#Single file
dotnet publish -c Release -p:PublishSingleFile=true
语言方式命令
PythonPyPI
pip install myapp
pipx install myapp
Python单文件
pyinstaller --onefile main.py
TypeScriptnpm
npm install -g myapp
TypeScript二进制文件
pkg .
bun build --compile
C#NuGet工具
dotnet tool install -g myapp
C#单文件
dotnet publish -c Release -p:PublishSingleFile=true