console-cli
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseConsole Apps & CLI Tools (.NET 10)
控制台应用与 CLI 工具(.NET 10)
Core Principles
核心原则
- stdout is data, stderr is everything else — Results go to stdout so works. Logs, progress, prompts and errors go to stderr. Mixing them breaks every pipe and script that consumes your tool.
mytool list | jq - Exit codes are the API — = success, non-zero = failure, and each failure class gets a stable code. Scripts and CI branch on exit codes, never on parsing your error text.
0 - Use System.CommandLine 2.x, not hand-rolled parsing — It went stable at 2.0 and gives help,
args, validation, tab completion and Ctrl+C handling for free. Hand parsing always misses--version, quoting, and help output.--opt=value - Cancellation flows from Ctrl+C to every await — System.CommandLine turns SIGINT/SIGTERM into a . Pass it all the way down, or Ctrl+C becomes a hard kill that corrupts half-written files.
CancellationToken - Generic Host only when you need it — DI, configuration, logging and justify
IHttpClientFactory. A 50-line utility does not; startup time matters for CLIs.Host.CreateApplicationBuilder
- stdout 是数据,stderr 承载其他所有内容 — 结果应写入 stdout,使 能够正常工作。日志、进度、提示和错误应写入 stderr。混用二者会破坏所有消费该工具的管道和脚本。
mytool list | jq - 退出码就是 API — 表示成功,非零值表示失败,每类失败都应分配一个稳定的代码。脚本和 CI 应依据退出码分支,而不是解析错误文本。
0 - 使用 System.CommandLine 2.x,不要手动实现 解析 — 它在 2.0 时已进入稳定阶段,并自动提供帮助信息、
args、校验、Tab 补全和 Ctrl+C 处理。手动解析总会遗漏--version、引号处理和帮助输出。--opt=value - 取消应从 Ctrl+C 传递到每一个 await — System.CommandLine 会将 SIGINT/SIGTERM 转换为 。必须将它一路向下传递,否则 Ctrl+C 会变成强制终止,导致只写入一半的文件损坏。
CancellationToken - 只在确有需要时使用 Generic Host — DI、配置、日志和 足以证明使用
IHttpClientFactory的价值。50 行的实用工具则不值得使用;CLI 的启动时间也很重要。Host.CreateApplicationBuilder
Patterns
模式
Command Tree with System.CommandLine 2.x
使用 System.CommandLine 2.x 构建命令树
Verified against System.CommandLine 2.0.x (stable). The beta-era APIs (, , ) are gone.
SetHandlerInvocationContextCommandLineBuildercsharp
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();Why: options are typed objects, so is compile-time checked. prints parse errors to stderr, shows help, and returns exit code without your code running. , , and keep input validation at the boundary.
GetValueInvokeAsync1RequiredAcceptExistingOnly()Validators已针对 System.CommandLine 2.0.x(稳定版)验证。beta 时期的 API(、、)已被移除。
SetHandlerInvocationContextCommandLineBuildercsharp
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();原因: 选项是类型化对象,因此 会在编译期接受检查。 会将解析错误打印到 stderr、显示帮助信息并返回退出码 ,且不会运行你的代码。、 和 可将输入校验保留在边界层。
GetValueInvokeAsync1RequiredAcceptExistingOnly()ValidatorsExit Codes as a Contract
将退出码作为契约
csharp
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;
}
}
}Why: named constants document the contract and stop magic numbers drifting. Catching only when the token fired distinguishes a user Ctrl+C from a real timeout bug. Document the codes in text or the README.
OperationCanceledException--helpcsharp
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;
}
}
}原因: 命名常量可以记录契约,并避免魔法数字发生漂移。仅在令牌已触发时捕获 ,可以区分用户按下 Ctrl+C 与真正的超时缺陷。请在 文本或 README 中记录这些代码。
OperationCanceledException--helpCtrl+C and Graceful Shutdown
Ctrl+C 与优雅关闭
ParseResult.InvokeAsyncInvocationConfiguration.ProcessTerminationTimeoutcsharp
InvocationConfiguration config = new()
{
ProcessTerminationTimeout = TimeSpan.FromSeconds(10) // time to flush a partial batch
};
return await root.Parse(args).InvokeAsync(config);Why: the default 2 seconds suits most tools; raise it only when cleanup (flushing, releasing a lock file) genuinely takes longer. Never install your own handler alongside it — two owners of Ctrl+C race each other.
Console.CancelKeyPressParseResult.InvokeAsyncInvocationConfiguration.ProcessTerminationTimeoutcsharp
InvocationConfiguration config = new()
{
ProcessTerminationTimeout = TimeSpan.FromSeconds(10) // time to flush a partial batch
};
return await root.Parse(args).InvokeAsync(config);原因: 默认的 2 秒适用于大多数工具;仅当清理工作(刷新数据、释放锁文件等)确实需要更长时间时才应提高该值。切勿同时安装自己的 处理器,否则两个 Ctrl+C 所有者会相互竞争。
Console.CancelKeyPressGeneric Host + DI Inside a CLI
在 CLI 中使用 Generic Host + DI
There is no stable package (it is still alpha), so compose them yourself: parse first, then build the host inside the action that needs services.
System.CommandLine.Hostingcsharp
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; }
}Why: and parse errors stay instant because no host is built for them. The default console logger writes to stdout. Setting sends every log line to stderr and keeps stdout clean. You never call for a run-to-completion CLI; you only want its container.
--helpLogToStandardErrorThreshold = LogLevel.Tracehost.RunAsync()System.CommandLine.Hostingcsharp
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; }
}原因: 和解析错误始终即时返回,因为不会为它们构建 host。默认的控制台 logger 会写入 stdout。设置 后,每条日志都会发送到 stderr,从而保持 stdout 整洁。对于运行至结束的 CLI,不要调用 ;你只需要它的 DI 容器。
--helpLogToStandardErrorThreshold = LogLevel.Tracehost.RunAsync()Interactive vs Non-Interactive
交互式与非交互式
csharp
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;
}Why: a prompt in CI hangs the job forever. Detect redirection and fail fast with an actionable message. Every prompt needs a flag that bypasses it (, ). Prompts go to stderr so they never pollute piped stdout.
--force--yescsharp
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 中的交互提示会让作业永久挂起。应检测输入输出重定向并快速失败,同时给出可操作的错误信息。每个提示都需要一个可以跳过它的标志(、)。提示应写入 stderr,避免污染通过管道传输的 stdout。
--force--yesReading stdin
读取 stdin
csharp
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);Why: accepting a file or stdin (the Unix convention) makes the tool composable. Stream line by line; never input of unknown size.
-ReadToEndcsharp
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 的 约定)输入,可让工具更容易与其他工具组合。应逐行流式处理;绝不以 读取大小未知的输入。
-ReadToEndPackaging as a .NET Tool
打包为 .NET Tool
xml
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<ToolCommandName>orders</ToolCommandName>
<PackageId>Contoso.Orders.Cli</PackageId>
</PropertyGroup>bash
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 shorthandWhy: a tool package is versioned, installable and updatable through NuGet with no installer. In .NET 10 a tool can also ship platform-specific (RID) builds, including Native AOT, in one package. List the RIDs in and add as a framework-dependent fallback. See the skill.
<RuntimeIdentifiers>anynative-aotxml
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<ToolCommandName>orders</ToolCommandName>
<PackageId>Contoso.Orders.Cli</PackageId>
</PropertyGroup>bash
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,并添加 作为依赖框架的后备版本。请参阅 skill。
<RuntimeIdentifiers>anynative-aotFile-Based Apps for Scripts (dotnet run app.cs
)
dotnet run app.cs用于脚本的基于文件的应用(dotnet run app.cs
)
dotnet run app.cscsharp
#!/usr/bin/env -S dotnet --
#:package System.CommandLine@2.0.14
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();bash
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 growsWhy: single-file apps replace PowerShell/bash glue scripts with typed C#, with no . without a version only works under central package management, so pin a version or use . Keep them outside any project folder so that project's doesn't leak in. Convert once the script needs tests or more than one file.
.csproj#:package@*Directory.Build.propscsharp
#!/usr/bin/env -S dotnet --
#:package System.CommandLine@2.0.14
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();bash
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.propsAnti-patterns
反模式
Logging to stdout
将日志写入 stdout
csharp
// 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);csharp
// 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);Throwing for Expected Failures
对预期内的失败抛出异常
csharp
// 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;
}csharp
// 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;
}Sync-over-Async and Ignored Tokens
同步阻塞异步与忽略 CancellationToken
csharp
// 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();Don't mix sync and async actions in one app. Pick async throughout.
Invoke()csharp
// 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()Using Pre-2.0 System.CommandLine APIs
使用 System.CommandLine 2.0 之前的 API
csharp
// 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();csharp
// 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
in Tools
DateTime.Now在工具中使用 DateTime.Now
DateTime.Nowcsharp
// 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);csharp
// 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);Decision Guide
决策指南
| Scenario | Recommendation |
|---|---|
| One-off script / automation glue | File-based app ( |
| Script grows past one file or needs tests | |
| Any tool with options or subcommands | System.CommandLine 2.x |
| Tool needs HTTP, config, or DI | Build a |
| Simple tool with no services | No host; plain |
| Rich terminal UI (tables, spinners) | Spectre.Console, rendered to stderr unless it is the output |
| Distribute to .NET developers | |
| Distribute to non-.NET users | Native AOT single binary per RID (see |
| Long-running daemon, not run-to-completion | Worker Service with |
| Destructive command | Interactive confirm + |
| Testing a command | Call |
| 场景 | 建议 |
|---|---|
| 一次性脚本或自动化胶水代码 | 基于文件的应用( |
| 脚本扩展到多个文件或需要测试 | 执行 |
| 任何带选项或子命令的工具 | System.CommandLine 2.x |
| 工具需要 HTTP、配置或 DI | 在操作内部构建 |
| 不需要任何服务的简单工具 | 不使用 host;采用普通 |
| 丰富的终端 UI(表格、加载动画) | 使用 Spectre.Console,除非它本身就是输出,否则渲染到 stderr |
| 分发给 .NET 开发者 | |
| 分发给非 .NET 用户 | 按 RID 提供 Native AOT 单文件二进制(参阅 |
| 长时间运行且非运行至结束的守护进程 | 使用带 |
| 破坏性命令 | 交互式确认,并为 CI 提供 |
| 测试命令 | 调用 |