mcpField
D
dotnet-system-commandline

dotnet-system-commandline

github.com/wshaddix开发8675 次安装35 周前更新
代码仓库

关于 dotnet-system-commandline

面向 .NET CLI 开发者的技能指南,介绍基于 System.CommandLine 2.0 GA API 构建命令行应用。重点涵盖命令结构、解析、验证、补全和测试,并标明与旧 beta API 的差异。

主要功能

  1. 01构建命令与子命令层级
  2. 02配置选项、参数及自定义解析
  3. 03使用 SetAction 绑定处理逻辑
  4. 04添加验证规则与制表符补全
  5. 05通过 TextWriter 捕获输出进行测试

适用场景

  1. 01将 beta4 写法迁移到 2.0 GA
  2. 02为 CLI 参数添加类型解析和校验
  3. 03为命令行应用设置 Shell 补全
  4. 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 上提供。它读取以下内容:

  1. AssemblyInformationalVersionAttribute(优先使用——包含 SemVer 元数据)
  2. 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 API2.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 = trueoption.Required = true
option.IsHidden = trueoption.Hidden = true
InvocationContext contextParseResult parseResult(在 SetAction 中)
context.GetCancellationToken()CancellationToken ct(异步 SetAction 中的第二个参数)
context.ConsoleInvocationConfiguration.Output / .Error
IConsole / TestConsole通过 InvocationConfiguration 使用 StringWriter
new CommandLineBuilder(root).UseDefaults().Build()root.Parse(args)(内置中间件)
builder.AddMiddleware(...)已移除——检查 ParseResult.Action 或包装 Invoke
CommandLineBuilderParserConfiguration + InvocationConfiguration
UseCommandHandler<T,T> (Hosting)直接构建主机,在 SetAction 中解析服务
Parser classCommandLineParser (static class)
FindResultFor(symbol)GetResult(symbol)
ErrorMessage = "..."result.AddError("...")
getDefaultValue: () => valDefaultValueFactory = _ => val
ParseArgument<T> delegateCustomParser property

Agent 注意事项

  1. 不要使用 beta4 API 模式。 2.0.0 GA API 有根本性差异。没有 SetHandler,请使用 SetAction。没有 InvocationContext,操作会直接接收 ParseResult。没有 CommandLineBuilder,配置使用 ParserConfiguration/InvocationConfiguration。
  2. 不要引用已停用的包。 System.CommandLine.Hosting、System.CommandLine.NamingConventionBinder 和 System.CommandLine.Rendering 均已停用。请使用单一的 System.CommandLine 包。
  3. 不要混淆 Option<T> 和 Argument<T>。 选项带名称(--output file.txt),参数按位置传入(mycli file.txt)。使用错误的类型会导致难以理解的解析错误。
  4. 不要使用 AddOption/AddCommand/AddAlias 方法。 这些方法已替换为可变集合属性:Options.Add、Subcommands.Add、Aliases.Add。旧方法在 2.0.0 中不存在。
  5. 不要使用 IConsole 或 TestConsole 进行测试。 这些接口已移除。请使用 InvocationConfiguration,并通过 StringWriter 设置 Output/Error 以捕获测试输出。
  6. 不要在异步操作中忽略 CancellationToken。 在 2.0.0 GA 中,CancellationToken 是异步 SetAction 委托的必需第二个参数。未传递该参数时,编译器会发出警告(CA2016)。
  7. 不要在命令操作中直接写入 Console.Out。 请写入 InvocationConfiguration.Output,以便进行测试。如果未提供配置,输出默认会写入 Console.Out,但直接写入会绕过测试捕获。
  8. 不要通过构造函数设置默认值。 请改用 DefaultValueFactory 属性。2.0.0 中不存在旧的 getDefaultValue 构造函数参数。

参考资料


署名

改编自 Aaronontheweb/dotnet-skills(MIT 许可证)。

评论

支持 Markdown