关于 console-cli
console-cli 是为 .NET 10 控制台应用和命令行工具提供设计与实现指导的技能。它强调可组合的输入输出、稳定的退出码约定、取消处理,以及合适的应用与打包方式。
主要功能
- 01System.CommandLine 2.x 命令模式
- 02区分标准输出与标准错误
- 03稳定的退出码设计
- 04处理 Ctrl+C 取消
- 05工具、脚本与主机打包指导
适用场景
- 01构建带选项和子命令的命令行工具
- 02创建可接收文件或标准输入的脚本
- 03通过 NuGet 分发 .NET 工具
- 04让命令适用于 CI 和 Shell 管道
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill console-cli安装后在 Claude Code 中输入/console-cli确认已加载。
详细介绍
控制台应用和命令行工具(.NET 10)
核心原则
- stdout 是数据,stderr 是其他所有内容 — 结果输出到 stdout,这样
mytool list | jq才能正常工作。日志、进度、提示和错误输出到 stderr。混用会破坏所有使用你工具的管道和脚本。 - 退出代码就是 API —
0= 成功,非零 = 失败,每种失败类别都有稳定的代码。脚本和 CI 根据退出代码分支,绝不解析错误文本。 - 使用 System.CommandLine 2.x,不要手动解析
args— 它在 2.0 版正式稳定,并免费提供帮助、--version、验证、Tab 补全和 Ctrl+C 处理。手动解析总会遗漏--opt=value、引号处理和帮助输出。 - 取消信号要从 Ctrl+C 传递到每个 await — System.CommandLine 会将 SIGINT/SIGTERM 转换为
CancellationToken。务必一路传递,否则 Ctrl+C 会变成强制终止,损坏写入了一半的文件。 - 只有在需要时才使用 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 }),并断言退出代码和输出 |