mcpField
B
building-cli-apps

building-cli-apps

github.com/mhagrelius开发35 次安装
代码仓库

关于 building-cli-apps

面向命令行工具开发者的设计指南技能,介绍如何构建可脚本化的应用,涵盖管道输入输出、命令结构、配置、交互、测试与分发,并提供 Python、TypeScript 和 C# 示例。

主要功能

  1. 01CLI、TUI 与 GUI 选择指南
  2. 02标准流与退出码模式
  3. 03配置优先级与子命令
  4. 04管道输入与信号处理
  5. 05测试和分发示例

适用场景

  1. 01设计可组合进 Shell 管道的 CLI
  2. 02规划命令结构与配置
  3. 03支持交互式和自动化运行
  4. 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 的场景: 面向非技术用户、复杂可视化、拖放操作

快速参考:各语言对应的库

语言参数解析进度条/加载动画颜色提示
Pythontyper(现代)或 clickrich.progressrichrich.prompt
TypeScriptcommander 或 yargsorachalkinquirer
C#System.CommandLineSpectre.ConsoleSpectre.ConsoleSpectre.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使用错误参数无效
130SIGINTCtrl+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. 配置层级

优先级(从高到低):

  1. CLI 参数(--config value)
  2. 环境变量(APP_CONFIG)
  3. 配置文件(.apprc、config.json)
  4. 默认值
# 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

错误消息

良好的错误消息应包含:

  1. 出了什么问题
  2. 为什么这是个问题
  3. 如何修复
# 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)

分发

语言方式命令
PythonPyPIpip install myapp 或 pipx install myapp
Python单文件pyinstaller --onefile main.py
TypeScriptnpmnpm install -g myapp
TypeScript二进制文件pkg . 或 bun build --compile
C#NuGet 工具dotnet tool install -g myapp
C#单文件dotnet publish -c Release -p:PublishSingleFile=true

评论

支持 Markdown