building-cli-apps
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuilding 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
按语言分类的工具库速查
| Language | Argument Parsing | Progress/Spinners | Colors | Prompts |
|---|---|---|---|---|
| Python | | | | |
| TypeScript | | | | |
| C# | | | | |
| 语言 | 参数解析 | 进度/加载动画 | 颜色输出 | 交互提示 |
|---|---|---|---|---|
| Python | | | | |
| TypeScript | | | | |
| C# | | | | |
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..."); // stderrstdout → 数据/结果(支持管道传递)
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..."); // stderr2. Exit Codes
2. 退出码
| Code | Meaning | Use When |
|---|---|---|
| 0 | Success | Operation completed |
| 1 | General error | User/input errors |
| 2 | Misuse | Invalid arguments |
| 130 | SIGINT | Ctrl+C interrupted |
python
undefined| 代码 | 含义 | 使用场景 |
|---|---|---|
| 0 | 成功 | 操作完成 |
| 1 | 通用错误 | 用户/输入错误 |
| 2 | 使用不当 | 参数无效 |
| 130 | SIGINT | Ctrl+C中断 |
python
undefinedPython
Python
import sys
sys.exit(0) # Success
sys.exit(1) # Error
```typescript
// TypeScript
process.exit(0);
process.exitCode = 1; // Preferred - allows cleanupcsharp
// C#
Environment.Exit(0);
return 1; // From Mainimport 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):
- CLI arguments ()
--config value - Environment variables ()
APP_CONFIG - Config file (,
.apprc)config.json - Defaults
python
undefined优先级(从高到低):
- CLI参数()
--config value - 环境变量()
APP_CONFIG - 配置文件(,
.apprc)config.json - 默认值
python
undefinedPython with typer
Python + typer
import typer
import os
def main(
config: str = typer.Option(
os.environ.get("APP_CONFIG", "default"),
"--config", "-c"
)
):
pass
undefinedimport typer
import os
def main(
config: str = typer.Option(
os.environ.get("APP_CONFIG", "default"),
"--config", "-c"
)
):
pass
undefined4. 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.pyPython with typer:
python
undefinedmycli/
├── src/
│ ├── main.py # 入口文件,注册命令
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── process.py # mycli process <file>
│ │ └── config.py # mycli config show|set
│ └── lib/ # 共享逻辑
└── tests/
└── commands/
└── test_process.pyPython + typer示例:
python
undefinedmain.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: or
cat data.txt | mycli process -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-Pattern | Problem | Fix |
|---|---|---|
| Progress to stdout | Breaks piping | Use stderr |
| Silent failures | User doesn't know what failed | Print error + exit non-zero |
No | 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 |
| Catching all exceptions | Hides bugs | Catch specific, let others crash |
| 反模式 | 问题 | 修复方案 |
|---|---|---|
| 进度输出到stdout | 破坏管道传递 | 使用stderr输出 |
| 静默失败 | 用户无法得知错误原因 | 打印错误信息 + 非零码退出 |
无 | 工具不可用 | 使用typer/commander(自动生成帮助) |
| 硬编码路径 | 不具备可移植性 | 使用环境变量或配置文件 |
| 无退出码 | 脚本无法判断操作是否成功 | 合理返回0/1退出码 |
| 管道模式下要求确认 | 导致自动化流程挂起 | 检查 |
| 捕获所有异常 | 隐藏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 == 0TypeScript 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 == 0TypeScript + 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
"""
passpython
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
"""
passError Messages
错误消息
Good error messages include:
- What went wrong
- Why it's a problem
- How to fix it
python
undefined优质错误消息应包含:
- 错误内容
- 错误原因
- 修复方法
python
undefinedBad
不良示例
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)
undefinedprint(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)
undefinedDistribution
分发方式
| Language | Method | Command |
|---|---|---|
| Python | PyPI | |
| Python | Single file | |
| TypeScript | npm | |
| TypeScript | Binary | |
| C# | NuGet tool | |
| C# | Single file | |
| 语言 | 方式 | 命令 |
|---|---|---|
| Python | PyPI | |
| Python | 单文件 | |
| TypeScript | npm | |
| TypeScript | 二进制文件 | |
| C# | NuGet工具 | |
| C# | 单文件 | |