console-cli

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Console Apps & CLI Tools (.NET 10)

控制台应用与 CLI 工具(.NET 10)

Core Principles

核心原则

  1. stdout is data, stderr is everything else — Results go to stdout so
    mytool list | jq
    works. Logs, progress, prompts and errors go to stderr. Mixing them breaks every pipe and script that consumes your tool.
  2. Exit codes are the API —
    0
    = 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.
  3. Use System.CommandLine 2.x, not hand-rolled
    args
    parsing
    — It went stable at 2.0 and gives help,
    --version
    , validation, tab completion and Ctrl+C handling for free. Hand parsing always misses
    --opt=value
    , quoting, and help output.
  4. Cancellation flows from Ctrl+C to every await — System.CommandLine turns SIGINT/SIGTERM into a
    CancellationToken
    . Pass it all the way down, or Ctrl+C becomes a hard kill that corrupts half-written files.
  5. Generic Host only when you need it — DI, configuration, logging and
    IHttpClientFactory
    justify
    Host.CreateApplicationBuilder
    . A 50-line utility does not; startup time matters for CLIs.
  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 — DI、配置、日志和
    IHttpClientFactory
    足以证明使用
    Host.CreateApplicationBuilder
    的价值。50 行的实用工具则不值得使用;CLI 的启动时间也很重要。

Patterns

模式

Command Tree with System.CommandLine 2.x

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

Verified against System.CommandLine 2.0.x (stable). The beta-era APIs (
SetHandler
,
InvocationContext
,
CommandLineBuilder
) are gone.
csharp
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
GetValue
is compile-time checked.
InvokeAsync
prints parse errors to stderr, shows help, and returns exit code
1
without your code running.
Required
,
AcceptExistingOnly()
, and
Validators
keep input validation at the boundary.
已针对 System.CommandLine 2.0.x(稳定版)验证。beta 时期的 API(
SetHandler
、
InvocationContext
、
CommandLineBuilder
)已被移除。
csharp
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
可将输入校验保留在边界层。

Exit 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
OperationCanceledException
only when the token fired distinguishes a user Ctrl+C from a real timeout bug. Document the codes in
--help
text or the README.
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;
        }
    }
}
原因: 命名常量可以记录契约,并避免魔法数字发生漂移。仅在令牌已触发时捕获
OperationCanceledException
,可以区分用户按下 Ctrl+C 与真正的超时缺陷。请在
--help
文本或 README 中记录这些代码。

Ctrl+C and Graceful Shutdown

Ctrl+C 与优雅关闭

ParseResult.InvokeAsync
cancels the action's token on Ctrl+C, SIGINT and SIGTERM.
InvocationConfiguration.ProcessTerminationTimeout
(default 2 seconds) is how long the action gets to finish before the process is killed.
csharp
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
Console.CancelKeyPress
handler alongside it — two owners of Ctrl+C race each other.
ParseResult.InvokeAsync
会在 Ctrl+C、SIGINT 和 SIGTERM 时取消操作的令牌。
InvocationConfiguration.ProcessTerminationTimeout
(默认 2 秒)决定操作在进程被强制终止前可完成清理的时间。
csharp
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 Inside a CLI

在 CLI 中使用 Generic Host + DI

There is no stable
System.CommandLine.Hosting
package (it is still alpha), so compose them yourself: parse first, then build the host inside the action that needs services.
csharp
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:
--help
and parse errors stay instant because no host is built for them. The default console logger writes to stdout. Setting
LogToStandardErrorThreshold = LogLevel.Trace
sends every log line to stderr and keeps stdout clean. You never call
host.RunAsync()
for a run-to-completion CLI; you only want its container.
System.CommandLine.Hosting
尚无稳定版(仍为 alpha),因此需要自行组合:先进行解析,再在需要服务的操作内部构建 host。
csharp
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。默认的控制台 logger 会写入 stdout。设置
LogToStandardErrorThreshold = LogLevel.Trace
后,每条日志都会发送到 stderr,从而保持 stdout 整洁。对于运行至结束的 CLI,不要调用
host.RunAsync()
;你只需要它的 DI 容器。

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 (
--force
,
--yes
). Prompts go to stderr so they never pollute piped stdout.
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;
}
原因: CI 中的交互提示会让作业永久挂起。应检测输入输出重定向并快速失败,同时给出可操作的错误信息。每个提示都需要一个可以跳过它的标志(
--force
、
--yes
)。提示应写入 stderr,避免污染通过管道传输的 stdout。

Reading 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
ReadToEnd
input of unknown size.
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);
原因: 支持文件或 stdin(Unix 的
-
约定)输入,可让工具更容易与其他工具组合。应逐行流式处理;绝不以
ReadToEnd
读取大小未知的输入。

Packaging 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 shorthand
Why: 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
<RuntimeIdentifiers>
and add
any
as a framework-dependent fallback. See the
native-aot
skill.
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 shorthand
原因: 工具包可通过 NuGet 进行版本管理、安装和更新,无需额外安装器。在 .NET 10 中,一个工具包还可以在同一个包中提供特定平台(RID)的构建版本,包括 Native AOT。请在
<RuntimeIdentifiers>
中列出 RID,并添加
any
作为依赖框架的后备版本。请参阅
native-aot
skill。

File-Based Apps for Scripts (
dotnet run app.cs
)

用于脚本的基于文件的应用(
dotnet run app.cs
)

csharp
#!/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
Why: single-file apps replace PowerShell/bash glue scripts with typed C#, with no
.csproj
.
#:package
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
Directory.Build.props
doesn't leak in. Convert once the script needs tests or more than one file.
csharp
#!/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
原因: 单文件应用无需
.csproj
,即可使用类型化的 C# 取代 PowerShell/bash 胶水脚本。只有在中央包管理下,
#:package
才能省略版本号,因此应固定版本或使用
@*
。应将其放在任何项目文件夹之外,避免该项目的
Directory.Build.props
意外参与构建。当脚本需要测试或由多个文件组成时,再将其转换。

Anti-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
Invoke()
and async actions in one app. Pick async throughout.
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

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);
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

决策指南

ScenarioRecommendation
One-off script / automation glueFile-based app (
dotnet run app.cs
)
Script grows past one file or needs tests
dotnet project convert
, then a normal console project
Any tool with options or subcommandsSystem.CommandLine 2.x
Tool needs HTTP, config, or DIBuild a
Host.CreateApplicationBuilder()
inside the action, logs to stderr
Simple tool with no servicesNo host; plain
static
handlers
Rich terminal UI (tables, spinners)Spectre.Console, rendered to stderr unless it is the output
Distribute to .NET developers
PackAsTool
+ NuGet (
dotnet tool install
/
dnx
)
Distribute to non-.NET usersNative AOT single binary per RID (see
native-aot
)
Long-running daemon, not run-to-completionWorker Service with
host.RunAsync()
, not a CLI
Destructive commandInteractive confirm +
--force
flag for CI
Testing a commandCall
root.Parse([...]).InvokeAsync(new() { Output = sw, Error = errSw })
and assert exit code + output
场景建议
一次性脚本或自动化胶水代码基于文件的应用(
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 })
,并断言退出码和输出