mcpField
C
console-cli

console-cli

github.com/codewithmukesh开发807153 次安装2 天前更新
代码仓库

关于 console-cli

console-cli 是为 .NET 10 控制台应用和命令行工具提供设计与实现指导的技能。它强调可组合的输入输出、稳定的退出码约定、取消处理,以及合适的应用与打包方式。

主要功能

  1. 01System.CommandLine 2.x 命令模式
  2. 02区分标准输出与标准错误
  3. 03稳定的退出码设计
  4. 04处理 Ctrl+C 取消
  5. 05工具、脚本与主机打包指导

适用场景

  1. 01构建带选项和子命令的命令行工具
  2. 02创建可接收文件或标准输入的脚本
  3. 03通过 NuGet 分发 .NET 工具
  4. 04让命令适用于 CI 和 Shell 管道

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/codewithmukesh/dotnet-claude-kit --skill console-cli

安装后在 Claude Code 中输入/console-cli确认已加载。

详细介绍

控制台应用和命令行工具(.NET 10)

核心原则

  1. stdout 是数据,stderr 是其他所有内容 — 结果输出到 stdout,这样 mytool list | jq 才能正常工作。日志、进度、提示和错误输出到 stderr。混用会破坏所有使用你工具的管道和脚本。
  2. 退出代码就是 API — 0 = 成功,非零 = 失败,每种失败类别都有稳定的代码。脚本和 CI 根据退出代码分支,绝不解析错误文本。
  3. 使用 System.CommandLine 2.x,不要手动解析 args — 它在 2.0 版正式稳定,并免费提供帮助、--version、验证、Tab 补全和 Ctrl+C 处理。手动解析总会遗漏 --opt=value、引号处理和帮助输出。
  4. 取消信号要从 Ctrl+C 传递到每个 await — System.CommandLine 会将 SIGINT/SIGTERM 转换为 CancellationToken。务必一路传递,否则 Ctrl+C 会变成强制终止,损坏写入了一半的文件。
  5. 只有在需要时才使用 Generic Host — 依赖注入、配置、日志记录和 IHttpClientFactory 足以说明使用 Host.CreateApplicationBuilder 的合理性。50 行的工具不需要它;启动时间对命令行工具很重要。

模式

使用 System.CommandLine 2.x 构建命令树

已根据 System.CommandLine 2.0.x(稳定版)验证。Beta 阶段的 API(SetHandler、InvocationContext、CommandLineBuilder)已移除。

using System.CommandLine;

Option<FileInfo> inputOption = new("--input", "-i")
{
    Description = "File to import",
    Required = true
};
inputOption.AcceptExistingOnly();

Option<int> batchOption = new("--batch-size")
{
    Description = "Rows per batch",
    DefaultValueFactory = _ => 500
};
batchOption.Validators.Add(result =>
{
    if (result.GetValue(batchOption) is < 1 or > 10_000)
        result.AddError("--batch-size must be between 1 and 10000");
});

Option<bool> dryRunOption = new("--dry-run") { Description = "Validate without writing" };

Command importCommand = new("import", "Import orders from a CSV file")
{
    inputOption, batchOption, dryRunOption
};
importCommand.SetAction((parseResult, ct) => ImportCommand.RunAsync(
    parseResult.GetValue(inputOption)!,
    parseResult.GetValue(batchOption),
    parseResult.GetValue(dryRunOption),
    ct));

RootCommand root = new("Order tooling") { importCommand };
return await root.Parse(args).InvokeAsync();

原因: 选项是强类型对象,因此 GetValue 会在编译时进行检查。InvokeAsync 会将解析错误输出到 stderr、显示帮助,并返回退出代码 1,而不会运行你的代码。Required、AcceptExistingOnly() 和 Validators 可在输入边界处完成验证。

将退出代码作为契约
public static class ExitCodes
{
    public const int Success = 0;
    public const int Failure = 1;          // also what System.CommandLine returns on parse errors
    public const int ValidationFailed = 2;
    public const int NotFound = 3;
    public const int Cancelled = 130;      // 128 + SIGINT, the shell convention
}

public static class ImportCommand
{
    public static async Task<int> RunAsync(FileInfo input, int batchSize, bool dryRun, CancellationToken ct)
    {
        try
        {
            var rows = await CsvReader.ReadAsync(input, ct);
            if (rows.Errors is [_, ..])
            {
                foreach (var error in rows.Errors)
                    await Console.Error.WriteLineAsync(error);
                return ExitCodes.ValidationFailed;
            }

            if (!dryRun)
                await OrderImporter.ImportAsync(rows.Items, batchSize, ct);

            Console.WriteLine(rows.Items.Count); // the data result → stdout
            return ExitCodes.Success;
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            await Console.Error.WriteLineAsync("Cancelled.");
            return ExitCodes.Cancelled;
        }
    }
}

原因: 命名常量能说明契约,并避免魔法数字逐渐失控。仅在令牌已触发时捕获 OperationCanceledException,可以区分用户按下 Ctrl+C 和实际的超时错误。在 --help 文本或 README 中说明这些代码。

Ctrl+C 和优雅关闭

ParseResult.InvokeAsync 会在 Ctrl+C、SIGINT 和 SIGTERM 时取消操作的令牌。InvocationConfiguration.ProcessTerminationTimeout(默认 2 秒)指定操作在进程被终止前有多长时间完成。

InvocationConfiguration config = new()
{
    ProcessTerminationTimeout = TimeSpan.FromSeconds(10) // time to flush a partial batch
};
return await root.Parse(args).InvokeAsync(config);

原因: 默认的 2 秒适用于大多数工具;只有在清理工作(刷新数据、释放锁文件)确实需要更长时间时才调高。切勿同时安装自己的 Console.CancelKeyPress 处理程序——两个 Ctrl+C 处理方会互相竞争。

在命令行工具中使用 Generic Host 和 DI

目前没有稳定版的 System.CommandLine.Hosting 包(它仍处于 Alpha 阶段),因此需要自行组合:先解析,再在需要服务的操作中构建 Host。

Option<bool> verboseOption = new("--verbose", "-v") { Recursive = true }; // added to root, inherited by subcommands

importCommand.SetAction(async (parseResult, ct) =>
{
    using var host = AppHost.Build(parseResult.GetValue(verboseOption));
    var handler = host.Services.GetRequiredService<ImportHandler>();
    return await handler.RunAsync(parseResult.GetValue(inputOption)!, ct);
});

internal static class AppHost
{
    public static IHost Build(bool verbose)
    {
        // No Args: System.CommandLine owns the command line, not configuration.
        // ContentRoot = install dir, so a stray appsettings.json in the user's cwd is never loaded.
        var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
        {
            ContentRootPath = AppContext.BaseDirectory
        });
        builder.Logging.ClearProviders();
        builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // logs → stderr
        builder.Logging.SetMinimumLevel(verbose ? LogLevel.Debug : LogLevel.Warning);

        builder.Services.AddSingleton(TimeProvider.System);
        builder.Services.AddHttpClient<OrdersApiClient>();
        builder.Services.AddTransient<ImportHandler>();
        return builder.Build();
    }
}

internal sealed class ImportHandler(OrdersApiClient api, TimeProvider clock, ILogger<ImportHandler> logger)
{
    public async Task<int> RunAsync(FileInfo input, CancellationToken ct) { /* ... */ return ExitCodes.Success; }
}

原因: 不会为 --help 和解析错误构建 Host,因此它们能即时响应。默认的控制台日志记录器会写入 stdout。设置 LogToStandardErrorThreshold = LogLevel.Trace 可将所有日志行发送到 stderr,并保持 stdout 干净。对于运行完即退出的命令行工具,无需调用 host.RunAsync();你只需要它的容器。

交互式与非交互式
public static class Terminal
{
    public static bool IsInteractive =>
        !Console.IsInputRedirected && !Console.IsOutputRedirected &&
        Environment.GetEnvironmentVariable("CI") is null;
}

if (!force && Terminal.IsInteractive)
{
    Console.Error.Write($"Delete {count} orders? [y/N] ");
    if (Console.ReadLine()?.Trim() is not ("y" or "Y"))
        return ExitCodes.Cancelled;
}
else if (!force)
{
    await Console.Error.WriteLineAsync("Refusing to delete without --force in non-interactive mode.");
    return ExitCodes.ValidationFailed;
}

原因: 在 CI 中弹出提示会让作业永远挂起。检测输入是否被重定向,并通过明确的消息快速失败。每个提示都需要一个可跳过提示的标志(--force、--yes)。提示输出到 stderr,这样就不会污染通过管道传递的 stdout。

读取 stdin
Argument<FileInfo?> fileArgument = new("file") { Arity = ArgumentArity.ZeroOrOne };

TextReader reader = parseResult.GetValue(fileArgument) is { } file
    ? file.OpenText()
    : Console.In; // `cat orders.csv | mytool import`

while (await reader.ReadLineAsync(ct) is { } line)
    Process(line);

原因: 接受文件或 stdin(Unix 的 - 约定)能让工具更易于组合使用。逐行读取;对于大小未知的输入,绝不要使用 ReadToEnd。

打包为 .NET Tool
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <PackAsTool>true</PackAsTool>
  <ToolCommandName>orders</ToolCommandName>
  <PackageId>Contoso.Orders.Cli</PackageId>
</PropertyGroup>
dotnet pack -c Release
dotnet tool install --global --add-source ./bin/Release Contoso.Orders.Cli
dotnet tool exec Contoso.Orders.Cli -- import -i orders.csv   # .NET 10: run without installing
dnx Contoso.Orders.Cli import -i orders.csv                   # .NET 10 shorthand

原因: 工具包有版本号,可通过 NuGet 安装和更新,无需安装程序。在 .NET 10 中,一个工具包还可以包含特定于平台(RID)的构建版本,包括 Native AOT 版本。将 RID 列在 <RuntimeIdentifiers> 中,并添加 any 作为依赖框架的回退选项。参见 native-aot 技能。

用于脚本的基于文件的应用(dotnet run app.cs)
#!/usr/bin/env -S dotnet --
#:package [email protected]

using System.CommandLine;

Argument<string> nameArgument = new("name");
RootCommand root = new("Greets someone") { nameArgument };
root.SetAction(parseResult => Console.WriteLine($"Hello, {parseResult.GetValue(nameArgument)}"));
return root.Parse(args).Invoke();
dotnet run greet.cs -- Mukesh     # or: dotnet greet.cs Mukesh
dotnet publish greet.cs           # Native AOT executable by default
dotnet project convert greet.cs   # graduate to a .csproj when it grows

原因: 单文件应用可用类型安全的 C# 取代 PowerShell/bash 粘合脚本,无需 .csproj。不带版本号的 #:package 仅适用于集中式包管理,因此请指定版本或使用 @*。请将这些文件放在任何项目文件夹之外,以免该项目的 Directory.Build.props 设置影响它们。当脚本需要测试或涉及多个文件时,再将其转换为常规项目。

反模式

将日志写入 stdout
// BAD — default console logger + status text on stdout corrupts piped output
logger.LogInformation("Exporting...");
Console.WriteLine("Done!");
Console.WriteLine(json);

// GOOD — only the data goes to stdout
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
await Console.Error.WriteLineAsync("Exporting...");
Console.WriteLine(json);
对预期失败抛出异常
// BAD — stack trace for a missing file, exit code is whatever the default handler picks
var text = File.ReadAllText(path);

// GOOD — validate at the boundary, return a documented exit code
if (!File.Exists(path))
{
    await Console.Error.WriteLineAsync($"File not found: {path}");
    return ExitCodes.NotFound;
}
同步调用异步方法并忽略令牌
// BAD — blocks a thread, Ctrl+C cannot interrupt the download
root.SetAction(parseResult => Console.WriteLine(client.GetStringAsync(url).Result));

// GOOD — async action, token flows to every call
root.SetAction(async (parseResult, ct) =>
{
    var body = await client.GetStringAsync(parseResult.GetValue(urlOption), ct);
    Console.WriteLine(body);
    return ExitCodes.Success;
});
return await root.Parse(args).InvokeAsync();

不要在同一个应用中混用同步的 Invoke() 和异步操作。请始终使用异步方式。

使用 2.0 之前的 System.CommandLine API
// BAD — beta API, removed in 2.0
rootCommand.SetHandler(async (FileInfo file) => { ... }, fileOption);
await new CommandLineBuilder(rootCommand).UseDefaults().Build().InvokeAsync(args);

// GOOD — 2.x API
rootCommand.SetAction((parseResult, ct) => RunAsync(parseResult.GetValue(fileOption)!, ct));
await rootCommand.Parse(args).InvokeAsync();
在工具中使用 DateTime.Now
// BAD — untestable "older than 30 days" logic
var cutoff = DateTime.Now.AddDays(-30);

// GOOD — injectable clock (FakeTimeProvider in tests)
var cutoff = clock.GetUtcNow().AddDays(-30);

决策指南

场景建议
一次性脚本/自动化粘合代码基于文件的应用(dotnet run app.cs)
脚本扩展到多个文件或需要测试使用 dotnet project convert,然后创建常规控制台项目
带选项或子命令的工具System.CommandLine 2.x
工具需要 HTTP、配置或 DI在操作中构建 Host.CreateApplicationBuilder(),日志写入 stderr
不需要服务的简单工具不使用 Host;采用纯 static 处理程序
丰富的终端 UI(表格、旋转指示器)使用 Spectre.Console,渲染到 stderr,除非它本身就是输出
面向 .NET 开发者分发PackAsTool + NuGet(dotnet tool install / dnx)
面向非 .NET 用户分发每个 RID 构建一个 Native AOT 单文件二进制(参见 native-aot)
长时间运行的守护进程,而非运行至完成后退出使用带有 host.RunAsync() 的 Worker Service,而非 CLI
具有破坏性的命令交互式确认 + 供 CI 使用的 --force 标志
测试命令调用 root.Parse([...]).InvokeAsync(new() { Output = sw, Error = errSw }),并断言退出代码和输出

评论

支持 Markdown