关于 building-cli-apps
面向命令行工具开发者的设计指南技能,介绍如何构建可脚本化的应用,涵盖管道输入输出、命令结构、配置、交互、测试与分发,并提供 Python、TypeScript 和 C# 示例。
主要功能
- 01CLI、TUI 与 GUI 选择指南
- 02标准流与退出码模式
- 03配置优先级与子命令
- 04管道输入与信号处理
- 05测试和分发示例
适用场景
- 01设计可组合进 Shell 管道的 CLI
- 02规划命令结构与配置
- 03支持交互式和自动化运行
- 04测试并打包命令行工具
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mhagrelius/ dotfiles --skill building-cli-apps安装后在 Claude Code 中输入/building-cli-apps确认已加载。
详细介绍
构建命令行应用
概述
命令行应用是管道中的过滤器。它们读取输入、转换数据并写入输出。Unix 哲学同样适用:做好一件事,并与其他工具组合使用。
何时使用 CLI、TUI 或 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"];
}
选择 CLI 的场景: 单次操作、可通过管道传递、可编写脚本、CI/CD、简单提示 选择 TUI 的场景: 仪表盘、多视图导航、实时监控 选择 GUI 的场景: 面向非技术用户、复杂可视化、拖放操作
快速参考:各语言对应的库
| 语言 | 参数解析 | 进度条/加载动画 | 颜色 | 提示 |
|---|---|---|---|---|
| Python | typer(现代)或 click | rich.progress | rich | rich.prompt |
| TypeScript | commander 或 yargs | ora | chalk | inquirer |
| C# | System.CommandLine | Spectre.Console | Spectre.Console | Spectre.Console |
核心模式
1. 流:stdout 与 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. 退出代码
| 代码 | 含义 | 使用场景 |
|---|---|---|
| 0 | 成功 | 操作已完成 |
| 1 | 一般错误 | 用户/输入错误 |
| 2 | 使用错误 | 参数无效 |
| 130 | SIGINT | Ctrl+C 中断 |
# 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. 配置层级
优先级(从高到低):
- CLI 参数(
--config value) - 环境变量(
APP_CONFIG) - 配置文件(
.apprc、config.json) - 默认值
# Python with typer
import typer
import os
def main(
config: str = typer.Option(
os.environ.get("APP_CONFIG", "default"),
"--config", "-c"
)
):
pass
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
使用 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()
使用 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();
使用 System.CommandLine 的 C#:
var rootCommand = new RootCommand("My CLI");
rootCommand.AddCommand(ProcessCommand.Create());
rootCommand.AddCommand(ConfigCommand.Create());
await rootCommand.InvokeAsync(args);
5. 交互模式与非交互模式
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. 从 stdin 读取(管道输入)
同时支持文件参数和管道输入(使用 - 约定):
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');
}
用法: cat data.txt | mycli process - 或 echo "test" | mycli process -
7. 信号处理
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);
};
反模式
| 反模式 | 问题 | 修复方法 |
|---|---|---|
| 将进度信息输出到 stdout | 破坏管道处理 | 使用 stderr |
| 静默失败 | 用户不知道哪里失败了 | 打印错误并以非零代码退出 |
没有 --help | 无法使用 | 使用 typer/commander(自动生成) |
| 硬编码路径 | 不具备可移植性 | 使用环境变量或配置 |
| 没有退出代码 | 脚本无法检查是否成功 | 根据情况以 0/1 退出 |
| 在管道中要求确认 | 自动化流程会挂起 | 检查 isatty(),使用 --force |
| 捕获所有异常 | 掩盖程序错误 | 仅捕获特定异常,让其他异常导致程序崩溃 |
测试命令行应用
使用 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
使用 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();
});
帮助文本最佳实践
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
错误消息
良好的错误消息应包含:
- 出了什么问题
- 为什么这是个问题
- 如何修复
# 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)
分发
| 语言 | 方式 | 命令 |
|---|---|---|
| Python | PyPI | pip install myapp 或 pipx install myapp |
| Python | 单文件 | pyinstaller --onefile main.py |
| TypeScript | npm | npm install -g myapp |
| TypeScript | 二进制文件 | pkg . 或 bun build --compile |
| C# | NuGet 工具 | dotnet tool install -g myapp |
| C# | 单文件 | dotnet publish -c Release -p:PublishSingleFile=true |
评论
支持 Markdown