关于 dotnet-system-commandline
面向 .NET CLI 开发者的技能指南,介绍基于 System.CommandLine 2.0 GA API 构建命令行应用。重点涵盖命令结构、解析、验证、补全和测试,并标明与旧 beta API 的差异。
主要功能
- 01构建命令与子命令层级
- 02配置选项、参数及自定义解析
- 03使用 SetAction 绑定处理逻辑
- 04添加验证规则与制表符补全
- 05通过 TextWriter 捕获输出进行测试
适用场景
- 01将 beta4 写法迁移到 2.0 GA
- 02为 CLI 参数添加类型解析和校验
- 03为命令行应用设置 Shell 补全
- 04测试命令解析结果和输出
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-system-commandline安装后在 Claude Code 中输入/dotnet-system-commandline确认已加载。
详细介绍
dotnet-system-commandline
用于构建 .NET CLI 应用程序的 System.CommandLine 2.0 稳定 API。涵盖 RootCommand、Command、Option<T>、Argument<T>、用于处理程序绑定的 SetAction、基于 ParseResult 的值访问、自定义类型解析、验证、制表符补全,以及通过捕获 TextWriter 进行测试。
**版本假设:**以 .NET 8.0+ 为基线。System.CommandLine 2.0.0+(稳定的 NuGet 包,自 2025 年 11 月起正式发布)。所有示例均针对 2.0.0 GA API。
**破坏性变更说明:**System.CommandLine 2.0.0 GA 与预发布 beta4 API 有显著差异。主要变更:SetHandler 替换为 SetAction,ICommandHandler 移除,改用 SynchronousCommandLineAction/AsynchronousCommandLineAction,InvocationContext 移除(直接传递 ParseResult),CommandLineBuilder 和 AddMiddleware 移除,IConsole 移除,改用 TextWriter 属性;此外,System.CommandLine.Hosting/System.CommandLine.NamingConventionBinder 包已停用。请勿使用 beta 时代的模式。
**不在范围内:**CLI 应用程序架构模式(分层的命令/处理程序/服务设计、配置优先级、退出代码、stdin/stdout/stderr)——请参阅 [skill:dotnet-cli-architecture]。Native AOT 编译——请参阅 [skill:dotnet-native-aot]。CLI 分发策略——请参阅 [skill:dotnet-cli-distribution]。通用 CI/CD 模式——请参阅 [skill:dotnet-gha-patterns] 和 [skill:dotnet-ado-patterns]。DI 容器机制——请参阅 [skill:dotnet-csharp-dependency-injection]。通用编码标准——请参阅 [skill:dotnet-csharp-coding-standards]。
交叉参考:[skill:dotnet-cli-architecture] 介绍 CLI 设计模式,[skill:dotnet-native-aot] 介绍 CLI 工具的 AOT 发布,[skill:dotnet-csharp-dependency-injection] 介绍 DI 基础知识,[skill:dotnet-csharp-configuration] 介绍配置集成,[skill:dotnet-csharp-coding-standards] 介绍命名和样式约定。
包引用
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.*" />
</ItemGroup>
System.CommandLine 2.0 面向 .NET 8+ 和 .NET Standard 2.0。一个包即可提供所有功能——beta 时代的独立 System.CommandLine.Hosting、System.CommandLine.NamingConventionBinder 和 System.CommandLine.Rendering 包均已停用。
RootCommand 和 Command 层级结构
基本命令结构
using System.CommandLine;
// Root command -- the entry point
var rootCommand = new RootCommand("My CLI tool description");
// Add a subcommand via mutable collection
var listCommand = new Command("list", "List all items");
rootCommand.Subcommands.Add(listCommand);
// Nested subcommands: mycli migrate up
var migrateCommand = new Command("migrate", "Database migrations");
var upCommand = new Command("up", "Apply pending migrations");
var downCommand = new Command("down", "Revert last migration");
migrateCommand.Subcommands.Add(upCommand);
migrateCommand.Subcommands.Add(downCommand);
rootCommand.Subcommands.Add(migrateCommand);
集合初始化器语法
// Fluent collection initializer (commands, options, arguments)
RootCommand rootCommand = new("My CLI tool")
{
new Option<string>("--output", "-o") { Description = "Output file path" },
new Argument<FileInfo>("file") { Description = "Input file" },
new Command("list", "List all items")
{
new Option<int>("--limit") { Description = "Max items to return" }
}
};
选项和参数
Option<T>——命名参数
// Option<T> -- named parameter (--output, -o)
// name is the first parameter; additional params are aliases
var outputOption = new Option<FileInfo>("--output", "-o")
{
Description = "Output file path",
Required = true // was IsRequired in beta4
};
// Option with default value via DefaultValueFactory
var verbosityOption = new Option<int>("--verbosity")
{
Description = "Verbosity level (0-3)",
DefaultValueFactory = _ => 1
};
Argument<T>——位置参数
// Argument<T> -- positional parameter
// name is mandatory in 2.0 (used for help text)
var fileArgument = new Argument<FileInfo>("file")
{
Description = "Input file to process"
};
rootCommand.Arguments.Add(fileArgument);
受约束的值
var formatOption = new Option<string>("--format")
{
Description = "Output format"
};
formatOption.AcceptOnlyFromAmong("json", "csv", "table");
rootCommand.Options.Add(formatOption);
别名
// Aliases are separate from the name in 2.0
// First constructor param is the name; rest are aliases
var verboseOption = new Option<bool>("--verbose", "-v")
{
Description = "Enable verbose output"
};
// Or add aliases after construction
verboseOption.Aliases.Add("-V");
全局选项
// Global options are inherited by all subcommands
var debugOption = new Option<bool>("--debug")
{
Description = "Enable debug mode",
Recursive = true // makes it global (inherited by subcommands)
};
rootCommand.Options.Add(debugOption);
设置操作(命令处理程序)
在 2.0.0 GA 中,SetHandler 已替换为 SetAction。操作会直接接收 ParseResult(没有 InvocationContext)。
同步操作
var outputOption = new Option<FileInfo>("--output", "-o")
{
Description = "Output file path",
Required = true
};
var verbosityOption = new Option<int>("--verbosity")
{
DefaultValueFactory = _ => 1
};
rootCommand.Options.Add(outputOption);
rootCommand.Options.Add(verbosityOption);
rootCommand.SetAction(parseResult =>
{
var output = parseResult.GetValue(outputOption)!;
var verbosity = parseResult.GetValue(verbosityOption);
Console.WriteLine($"Output: {output.FullName}, Verbosity: {verbosity}");
return 0; // exit code
});
使用 CancellationToken 的异步操作
// Async actions receive ParseResult AND CancellationToken
rootCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var output = parseResult.GetValue(outputOption)!;
var verbosity = parseResult.GetValue(verbosityOption);
await ProcessAsync(output, verbosity, ct);
return 0;
});
按名称获取值
// Values can also be retrieved by symbol name (requires type parameter)
rootCommand.SetAction(parseResult =>
{
int delay = parseResult.GetValue<int>("--delay");
string? message = parseResult.GetValue<string>("--message");
Console.WriteLine($"Delay: {delay}, Message: {message}");
});
解析和调用
// Program.cs entry point -- parse then invoke
static int Main(string[] args)
{
var rootCommand = BuildCommand();
ParseResult parseResult = rootCommand.Parse(args);
return parseResult.Invoke();
}
// Async entry point
static async Task<int> Main(string[] args)
{
var rootCommand = BuildCommand();
ParseResult parseResult = rootCommand.Parse(args);
return await parseResult.InvokeAsync();
}
仅解析,不调用
// Parse-only mode: inspect results without running actions
ParseResult parseResult = rootCommand.Parse(args);
if (parseResult.Errors.Count > 0)
{
foreach (var error in parseResult.Errors)
{
Console.Error.WriteLine(error.Message);
}
return 1;
}
FileInfo? file = parseResult.GetValue(fileOption);
// Process directly without SetAction
自定义类型解析
CustomParser 属性
对于没有内置解析器的类型,请使用 Option<T> 或 Argument<T> 上的 CustomParser 属性。
public record ConnectionInfo(string Host, int Port);
var connectionOption = new Option<ConnectionInfo?>("--connection")
{
Description = "Connection as host:port",
CustomParser = result =>
{
var raw = result.Tokens.SingleOrDefault()?.Value;
if (raw is null)
{
result.AddError("--connection requires a value");
return null;
}
var parts = raw.Split(':');
if (parts.Length != 2 || !int.TryParse(parts[1], out var port))
{
result.AddError("Expected format: host:port");
return null;
}
return new ConnectionInfo(parts[0], port);
}
};
DefaultValueFactory
var portOption = new Option<int>("--port")
{
Description = "Server port",
DefaultValueFactory = _ => 8080 // type-safe default
};
将 CustomParser 与验证结合使用
var uriOption = new Option<Uri?>("--uri")
{
Description = "Target URI",
CustomParser = result =>
{
var raw = result.Tokens.SingleOrDefault()?.Value;
if (raw is null) return null;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
{
result.AddError("Invalid URI format");
return null;
}
if (uri.Scheme != "https")
{
result.AddError("Only HTTPS URIs are accepted");
return null;
}
return uri;
}
};
验证
选项和参数验证器
// Validators use Validators.Add (not AddValidator in 2.0)
var portOption = new Option<int>("--port") { Description = "Port number" };
portOption.Validators.Add(result =>
{
var value = result.GetValue(portOption);
if (value < 1 || value > 65535)
{
result.AddError("Port must be between 1 and 65535");
}
});
// Arity constraints
var tagsOption = new Option<string[]>("--tag")
{
Arity = new ArgumentArity(1, 5), // 1 to 5 tags
AllowMultipleArgumentsPerToken = true
};
内置验证器
// Accept only existing files/directories
var inputOption = new Option<FileInfo>("--input");
inputOption.AcceptExistingOnly();
// Accept only legal file names
var nameArg = new Argument<string>("name");
nameArg.AcceptLegalFileNamesOnly();
// Accept only from a set of values (moved from FromAmong)
var envOption = new Option<string>("--env");
envOption.AcceptOnlyFromAmong("dev", "staging", "prod");
配置
在 2.0.0 GA 中,CommandLineBuilder 已移除。配置分别使用 ParserConfiguration(用于解析)和 InvocationConfiguration(用于调用)。
解析器配置
using System.CommandLine;
var config = new ParserConfiguration
{
EnablePosixBundling = true, // -abc == -a -b -c (default: true)
};
// Response files enabled by default; disable with:
// config.ResponseFileTokenReplacer = null;
ParseResult parseResult = rootCommand.Parse(args, config);
调用配置
var invocationConfig = new InvocationConfiguration
{
// Redirect output for testing or customization
Output = Console.Out,
Error = Console.Error,
// Process termination handling (default: 2 seconds)
ProcessTerminationTimeout = TimeSpan.FromSeconds(5),
// Disable default exception handler for custom try/catch
EnableDefaultExceptionHandler = false
};
int exitCode = parseResult.Invoke(invocationConfig);
制表符补全
启用补全
RootCommand 内置了制表符补全功能,通过 SuggestDirective 实现(默认包含)。
用户可以为自己的 shell 注册补全:
# Bash -- add to ~/.bashrc
source <(mycli [suggest:bash])
# Zsh -- add to ~/.zshrc
source <(mycli [suggest:zsh])
# PowerShell -- add to $PROFILE
mycli [suggest:powershell] | Out-String | Invoke-Expression
# Fish
mycli [suggest:fish] | source
自定义补全
// Static completions
var envOption = new Option<string>("--environment");
envOption.CompletionSources.Add("development", "staging", "production");
// Dynamic completions
var branchOption = new Option<string>("--branch");
branchOption.CompletionSources.Add(ctx =>
[
new CompletionItem("main"),
new CompletionItem("develop"),
// Dynamically fetch branches
.. GetGitBranches().Select(b => new CompletionItem(b))
]);
自动生成 --version 和 --help
版本
--version 通过 VersionOption 自动在 RootCommand 上提供。它读取以下内容:
AssemblyInformationalVersionAttribute(优先使用——包含 SemVer 元数据)AssemblyVersionAttribute(备用)
<PropertyGroup>
<Version>1.2.3</Version>
<InformationalVersion>1.2.3+abc123</InformationalVersion>
</PropertyGroup>
帮助
RootCommand 通过 HelpOption 自动提供帮助。构造函数和 Description 属性中的描述会显示在帮助文本中。
指令
指令取代了 Beta 时代的部分 CommandLineBuilder 扩展。RootCommand 提供了一个 Directives 集合。
// Built-in directives (included by default on RootCommand):
// [suggest] -- tab completion suggestions
// Other available directives:
rootCommand.Directives.Add(new DiagramDirective()); // [diagram] -- shows parse tree
rootCommand.Directives.Add(new EnvironmentVariablesDirective()); // [env:VAR=value]
解析错误处理
// Customize parse error behavior
ParseResult result = rootCommand.Parse(args);
if (result.Action is ParseErrorAction parseError)
{
parseError.ShowTypoCorrections = true;
parseError.ShowHelp = false;
}
int exitCode = result.Invoke();
依赖注入模式
System.CommandLine.Hosting 包已在 2.0.0 GA 中停用。要集成 DI,请直接使用 Microsoft.Extensions.Hosting,并在解析前组合服务。
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.CommandLine;
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton<ISyncService, SyncService>();
services.AddSingleton<IFileSystem, PhysicalFileSystem>();
})
.Build();
var serviceProvider = host.Services;
var sourceOption = new Option<string>("--source") { Description = "Source endpoint" };
var syncCommand = new Command("sync", "Synchronize data");
syncCommand.Options.Add(sourceOption);
syncCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var syncService = serviceProvider.GetRequiredService<ISyncService>();
var source = parseResult.GetValue(sourceOption);
await syncService.SyncAsync(source!, ct);
return 0;
});
var rootCommand = new RootCommand("My CLI tool");
rootCommand.Subcommands.Add(syncCommand);
return await rootCommand.Parse(args).InvokeAsync();
测试
使用 InvocationConfiguration 测试(捕获 TextWriter)
IConsole 已在 2.0.0 GA 中移除。测试时,请通过 InvocationConfiguration 重定向输出。
[Fact]
public void ListCommand_WritesItems_ToOutput()
{
// Arrange
var outputWriter = new StringWriter();
var errorWriter = new StringWriter();
var config = new InvocationConfiguration
{
Output = outputWriter,
Error = errorWriter
};
var rootCommand = BuildRootCommand();
// Act
ParseResult parseResult = rootCommand.Parse("list --format json");
int exitCode = parseResult.Invoke(config);
// Assert
Assert.Equal(0, exitCode);
Assert.Contains("json", outputWriter.ToString());
Assert.Empty(errorWriter.ToString());
}
在不调用的情况下测试解析后的值
[Fact]
public void ParseResult_ExtractsOptionValues()
{
var portOption = new Option<int>("--port") { DefaultValueFactory = _ => 8080 };
var rootCommand = new RootCommand { portOption };
ParseResult result = rootCommand.Parse("--port 3000");
Assert.Equal(3000, result.GetValue(portOption));
Assert.Empty(result.Errors);
}
[Fact]
public void ParseResult_ReportsErrors_ForInvalidInput()
{
var portOption = new Option<int>("--port");
var rootCommand = new RootCommand { portOption };
ParseResult result = rootCommand.Parse("--port not-a-number");
Assert.NotEmpty(result.Errors);
}
测试自定义解析器
[Fact]
public void CustomParser_ParsesConnectionInfo()
{
var connOption = new Option<ConnectionInfo?>("--connection")
{
CustomParser = result =>
{
var parts = result.Tokens.Single().Value.Split(':');
return new ConnectionInfo(parts[0], int.Parse(parts[1]));
}
};
var rootCommand = new RootCommand { connOption };
ParseResult result = rootCommand.Parse("--connection localhost:5432");
var conn = result.GetValue(connOption);
Assert.Equal("localhost", conn!.Host);
Assert.Equal(5432, conn.Port);
}
使用 DI 服务进行测试
[Fact]
public async Task SyncCommand_CallsService()
{
var mockService = new Mock<ISyncService>();
var services = new ServiceCollection()
.AddSingleton(mockService.Object)
.BuildServiceProvider();
var sourceOption = new Option<string>("--source");
var syncCommand = new Command("sync") { sourceOption };
syncCommand.SetAction(async (ParseResult pr, CancellationToken ct) =>
{
var svc = services.GetRequiredService<ISyncService>();
await svc.SyncAsync(pr.GetValue(sourceOption)!, ct);
return 0;
});
var root = new RootCommand { syncCommand };
int exitCode = await root.Parse("sync --source https://api.example.com")
.InvokeAsync();
Assert.Equal(0, exitCode);
mockService.Verify(s => s.SyncAsync("https://api.example.com",
It.IsAny<CancellationToken>()), Times.Once);
}
响应文件
System.CommandLine 支持响应文件(@filename),用于传递大量参数。默认启用响应文件支持;可通过 ParserConfiguration.ResponseFileTokenReplacer = null 禁用。
# args.rsp
--source https://api.example.com
--output /tmp/results.json
--verbose
# Invoke with response file
mycli sync @args.rsp
从 Beta4 迁移到 2.0.0 GA
| Beta4 API | 2.0.0 GA 替代方案 |
|---|---|
command.SetHandler(...) | command.SetAction(...) |
command.AddOption(opt) | command.Options.Add(opt) |
command.AddCommand(sub) | command.Subcommands.Add(sub) |
command.AddArgument(arg) | command.Arguments.Add(arg) |
option.AddAlias("-x") | option.Aliases.Add("-x") |
option.AddValidator(...) | option.Validators.Add(...) |
option.IsRequired = true | option.Required = true |
option.IsHidden = true | option.Hidden = true |
InvocationContext context | ParseResult parseResult(在 SetAction 中) |
context.GetCancellationToken() | CancellationToken ct(异步 SetAction 中的第二个参数) |
context.Console | InvocationConfiguration.Output / .Error |
IConsole / TestConsole | 通过 InvocationConfiguration 使用 StringWriter |
new CommandLineBuilder(root).UseDefaults().Build() | root.Parse(args)(内置中间件) |
builder.AddMiddleware(...) | 已移除——检查 ParseResult.Action 或包装 Invoke |
CommandLineBuilder | ParserConfiguration + InvocationConfiguration |
UseCommandHandler<T,T> (Hosting) | 直接构建主机,在 SetAction 中解析服务 |
Parser class | CommandLineParser (static class) |
FindResultFor(symbol) | GetResult(symbol) |
ErrorMessage = "..." | result.AddError("...") |
getDefaultValue: () => val | DefaultValueFactory = _ => val |
ParseArgument<T> delegate | CustomParser property |
Agent 注意事项
- 不要使用 beta4 API 模式。 2.0.0 GA API 有根本性差异。没有
SetHandler,请使用SetAction。没有InvocationContext,操作会直接接收ParseResult。没有CommandLineBuilder,配置使用ParserConfiguration/InvocationConfiguration。 - 不要引用已停用的包。
System.CommandLine.Hosting、System.CommandLine.NamingConventionBinder和System.CommandLine.Rendering均已停用。请使用单一的System.CommandLine包。 - 不要混淆
Option<T>和Argument<T>。 选项带名称(--output file.txt),参数按位置传入(mycli file.txt)。使用错误的类型会导致难以理解的解析错误。 - 不要使用
AddOption/AddCommand/AddAlias方法。 这些方法已替换为可变集合属性:Options.Add、Subcommands.Add、Aliases.Add。旧方法在 2.0.0 中不存在。 - 不要使用
IConsole或TestConsole进行测试。 这些接口已移除。请使用InvocationConfiguration,并通过StringWriter设置Output/Error以捕获测试输出。 - 不要在异步操作中忽略
CancellationToken。 在 2.0.0 GA 中,CancellationToken是异步SetAction委托的必需第二个参数。未传递该参数时,编译器会发出警告(CA2016)。 - 不要在命令操作中直接写入
Console.Out。 请写入InvocationConfiguration.Output,以便进行测试。如果未提供配置,输出默认会写入Console.Out,但直接写入会绕过测试捕获。 - 不要通过构造函数设置默认值。 请改用
DefaultValueFactory属性。2.0.0 中不存在旧的getDefaultValue构造函数参数。
参考资料
署名
改编自 Aaronontheweb/dotnet-skills(MIT 许可证)。