mcpField
D
dotnet-spectre-console

dotnet-spectre-console

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

关于 dotnet-spectre-console

面向 .NET 开发者的 Spectre.Console 使用技能,涵盖丰富的终端输出与 Spectre.Console.Cli 命令解析。适用于构建交互式控制台界面和结构化命令行应用,并说明可测试输出等实践。

主要功能

  1. 01样式文本、表格、树与面板输出
  2. 02显示进度条、状态指示与动态内容
  3. 03提供文本、确认和选择提示
  4. 04使用 Spectre.Console.Cli 组织命令层级
  5. 05支持类型化设置、验证与自动帮助
  6. 06通过 IAnsiConsole 测试控制台输出

适用场景

  1. 01为命令行程序呈现表格和进度
  2. 02构建带分支命令的 CLI
  3. 03收集用户输入并验证设置
  4. 04在测试中记录和检查控制台输出

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-spectre-console

安装后在 Claude Code 中输入/dotnet-spectre-console确认已加载。

详细介绍

dotnet-spectre-console

使用 Spectre.Console 构建丰富的控制台输出(表格、树、进度条、提示、标记、实时显示),并使用 Spectre.Console.Cli 解析结构化命令行应用。适用于 Windows、macOS 和 Linux 终端。

**版本假设:**以 .NET 8.0+ 为基准。Spectre.Console 0.54.0(最新稳定版)。Spectre.Console.Cli 0.53.1(最新稳定版)。这两个包均面向 net8.0+ 和 netstandard2.0。

**范围边界:**此技能负责丰富的控制台输出和 Spectre.Console.Cli 命令解析。完整的 TUI 应用(窗口、菜单、对话框、视图)由 [skill:dotnet-terminal-gui] 负责。System.CommandLine 解析由 [skill:dotnet-system-commandline] 负责。CLI 应用架构和分发由 [skill:dotnet-cli-architecture] 和 [skill:dotnet-cli-distribution] 负责。

交叉引用:[skill:dotnet-terminal-gui] 介绍完整 TUI 的替代方案,[skill:dotnet-system-commandline] 介绍 System.CommandLine 的范围边界,[skill:dotnet-cli-architecture] 介绍 CLI 结构,[skill:dotnet-csharp-async-patterns] 介绍异步模式,[skill:dotnet-csharp-dependency-injection] 介绍 Spectre.Console.Cli 的 DI,[skill:dotnet-accessibility] 介绍 TUI 的无障碍限制和屏幕阅读器注意事项。


包引用

<ItemGroup>
  
  <PackageReference Include="Spectre.Console" Version="0.54.0" />

  
  <PackageReference Include="Spectre.Console.Cli" Version="0.53.1" />
</ItemGroup>

Spectre.Console.Cli 依赖 Spectre.Console——仅在需要 CLI 框架时才安装这两个包。若只需要丰富的输出,单独使用 Spectre.Console 即可。


标记和样式

Spectre.Console 使用受 BBCode 启发的标记语法来设置控制台输出样式。

基本标记
using Spectre.Console;

// Styled text with markup tags
AnsiConsole.MarkupLine("[bold red]Error:[/] File not found.");
AnsiConsole.MarkupLine("[green]Success![/] Build completed in [blue]2.3s[/].");
AnsiConsole.MarkupLine("[underline]https://example.com[/]");
AnsiConsole.MarkupLine("[dim italic]This is subtle text[/]");

// Nested styles
AnsiConsole.MarkupLine("[bold [red on white]Warning:[/] check config[/]");

// Escape brackets with double brackets
AnsiConsole.MarkupLine("Use [[bold]] for bold text.");
Figlet 文本
AnsiConsole.Write(
    new FigletText("Hello!")
        .Color(Color.Green)
        .Centered());
分隔线(水平线)
// Simple rule
AnsiConsole.Write(new Rule());

// Titled rule
AnsiConsole.Write(new Rule("[yellow]Section Title[/]"));

// Aligned rule
AnsiConsole.Write(new Rule("[blue]Left Aligned[/]").LeftJustified());

表格

var table = new Table();

// Add columns
table.AddColumn("Name");
table.AddColumn(new TableColumn("Age").Centered());
table.AddColumn(new TableColumn("City").RightAligned());

// Add rows
table.AddRow("Alice", "30", "Seattle");
table.AddRow("[green]Bob[/]", "25", "Portland");
table.AddRow("Charlie", "35", "Vancouver");

// Styling
table.Border(TableBorder.Rounded);
table.BorderColor(Color.Grey);
table.Title("[underline]Team Members[/]");
table.Caption("[dim]Updated daily[/]");

// Column configuration
table.Columns[0].PadLeft(2);
table.Columns[0].NoWrap();

AnsiConsole.Write(table);
嵌套表格
var innerTable = new Table()
    .AddColumn("Detail")
    .AddColumn("Value")
    .AddRow("Role", "Developer")
    .AddRow("Level", "Senior");

var outerTable = new Table()
    .AddColumn("Name")
    .AddColumn("Info")
    .AddRow("Alice", innerTable);

AnsiConsole.Write(outerTable);

树

var tree = new Tree("Solution");

// Add nodes
var srcNode = tree.AddNode("[yellow]src[/]");
var apiNode = srcNode.AddNode("Api");
apiNode.AddNode("Controllers/");
apiNode.AddNode("Program.cs");

var libNode = srcNode.AddNode("Library");
libNode.AddNode("Services/");

var testNode = tree.AddNode("[blue]tests[/]");
testNode.AddNode("Api.Tests/");

// Styling
tree.Style = Style.Parse("dim");

AnsiConsole.Write(tree);

面板

var panel = new Panel("This is [green]important[/] content.")
    .Header("[bold]Notice[/]")
    .Border(BoxBorder.Rounded)
    .BorderColor(Color.Blue)
    .Padding(2, 1)    // horizontal, vertical
    .Expand();         // fill available width

AnsiConsole.Write(panel);
使用列组合可渲染对象
AnsiConsole.Write(new Columns(
    new Panel("Left panel").Expand(),
    new Panel("Right panel").Expand()));

进度显示

进度条
await AnsiConsole.Progress()
    .AutoClear(false)       // keep completed tasks visible
    .HideCompleted(false)
    .Columns(
        new TaskDescriptionColumn(),
        new ProgressBarColumn(),
        new PercentageColumn(),
        new RemainingTimeColumn(),
        new SpinnerColumn())
    .StartAsync(async ctx =>
    {
        var downloadTask = ctx.AddTask("[green]Downloading[/]", maxValue: 100);
        var extractTask = ctx.AddTask("[blue]Extracting[/]", maxValue: 100);

        while (!ctx.IsFinished)
        {
            await Task.Delay(50);
            downloadTask.Increment(1.5);

            if (downloadTask.Value > 50)
            {
                extractTask.Increment(0.8);
            }
        }
    });
状态旋转指示器
await AnsiConsole.Status()
    .Spinner(Spinner.Known.Dots)
    .SpinnerStyle(Style.Parse("green bold"))
    .StartAsync("Processing...", async ctx =>
    {
        await Task.Delay(1000);
        ctx.Status("Compiling...");
        ctx.Spinner(Spinner.Known.Star);
        await Task.Delay(1000);
        ctx.Status("Publishing...");
        await Task.Delay(1000);
    });

提示

文本提示
// Simple typed input
var name = AnsiConsole.Ask<string>("What's your [green]name[/]?");
var age = AnsiConsole.Ask<int>("What's your [green]age[/]?");

// With default value
var city = AnsiConsole.Prompt(
    new TextPrompt<string>("Enter [green]city[/]:")
        .DefaultValue("Seattle")
        .ShowDefaultValue());

// Secret input (password)
var password = AnsiConsole.Prompt(
    new TextPrompt<string>("Enter [green]password[/]:")
        .Secret());

// With validation
var email = AnsiConsole.Prompt(
    new TextPrompt<string>("Enter [green]email[/]:")
        .Validate(input =>
            input.Contains('@') && input.Contains('.')
                ? ValidationResult.Success()
                : ValidationResult.Error("[red]Invalid email address[/]")));

// Optional (allow empty)
var nickname = AnsiConsole.Prompt(
    new TextPrompt<string>("Enter [green]nickname[/] (optional):")
        .AllowEmpty());
确认提示
bool proceed = AnsiConsole.Confirm("Continue with deployment?");
选择提示
var fruit = AnsiConsole.Prompt(
    new SelectionPrompt<string>()
        .Title("Pick a [green]fruit[/]:")
        .PageSize(10)
        .EnableSearch()
        .WrapAround()
        .AddChoices("Apple", "Banana", "Orange", "Mango", "Grape"));

// Grouped choices
var country = AnsiConsole.Prompt(
    new SelectionPrompt<string>()
        .Title("Select [green]destination[/]:")
        .AddChoiceGroup("Europe", "France", "Italy", "Spain")
        .AddChoiceGroup("Asia", "Japan", "Thailand", "Vietnam"));
多选提示
var toppings = AnsiConsole.Prompt(
    new MultiSelectionPrompt<string>()
        .Title("Choose [green]toppings[/]:")
        .PageSize(10)
        .Required()
        .InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept)[/]")
        .AddChoices("Cheese", "Pepperoni", "Mushrooms", "Olives", "Onions"));

实时显示

实时显示会原地更新随时间变化的动态内容。

var table = new Table()
    .AddColumn("Time")
    .AddColumn("Status");

await AnsiConsole.Live(table)
    .AutoClear(false)
    .Overflow(VerticalOverflow.Ellipsis)
    .Cropping(VerticalOverflowCropping.Bottom)
    .StartAsync(async ctx =>
    {
        table.AddRow(DateTime.Now.ToString("T"), "[yellow]Starting...[/]");
        ctx.Refresh();
        await Task.Delay(1000);

        table.AddRow(DateTime.Now.ToString("T"), "[green]Processing...[/]");
        ctx.Refresh();
        await Task.Delay(1000);

        table.AddRow(DateTime.Now.ToString("T"), "[blue]Complete![/]");
        ctx.Refresh();
    });
替换目标
await AnsiConsole.Live(new Markup("[yellow]Initializing...[/]"))
    .StartAsync(async ctx =>
    {
        await Task.Delay(1000);
        ctx.UpdateTarget(new Markup("[green]Ready![/]"));
        await Task.Delay(1000);
        ctx.UpdateTarget(
            new Panel("Final result: [bold]42[/]")
                .Header("Done")
                .Border(BoxBorder.Rounded));
    });

Spectre.Console.Cli 框架

Spectre.Console.Cli 提供结构化的命令行解析框架,支持命令层级、类型化设置、验证和自动生成帮助信息。

基本命令应用
using Spectre.Console.Cli;

var app = new CommandApp<GreetCommand>();
return app.Run(args);

// Command with typed settings
public sealed class GreetSettings : CommandSettings
{
    [CommandArgument(0, "<name>")]
    [Description("The person to greet")]
    public string Name { get; init; } = string.Empty;

    [CommandOption("-c|--count")]
    [Description("Number of times to greet")]
    [DefaultValue(1)]
    public int Count { get; init; }

    [CommandOption("--shout")]
    [Description("Greet in uppercase")]
    public bool Shout { get; init; }
}

public sealed class GreetCommand : Command<GreetSettings>
{
    public override int Execute(CommandContext context, GreetSettings settings)
    {
        for (int i = 0; i < settings.Count; i++)
        {
            var greeting = $"Hello, {settings.Name}!";
            AnsiConsole.MarkupLine(settings.Shout
                ? $"[bold]{greeting.ToUpperInvariant()}[/]"
                : greeting);
        }
        return 0;  // exit code
    }
}
带分支的命令层级
var app = new CommandApp();
app.Configure(config =>
{
    config.AddBranch<RemoteSettings>("remote", remote =>
    {
        remote.AddCommand<RemoteAddCommand>("add")
            .WithDescription("Add a remote");
        remote.AddCommand<RemoteRemoveCommand>("remove")
            .WithDescription("Remove a remote");
    });

    config.AddCommand<CloneCommand>("clone")
        .WithDescription("Clone a repository");
});

return app.Run(args);

// Shared settings for the branch -- inherited by subcommands
public class RemoteSettings : CommandSettings
{
    [CommandOption("-v|--verbose")]
    [Description("Verbose output")]
    public bool Verbose { get; init; }
}

// Subcommand settings inherit from branch settings
public sealed class RemoteAddSettings : RemoteSettings
{
    [CommandArgument(0, "<name>")]
    public string Name { get; init; } = string.Empty;

    [CommandArgument(1, "<url>")]
    public string Url { get; init; } = string.Empty;
}

public sealed class RemoteAddCommand : Command<RemoteAddSettings>
{
    public override int Execute(CommandContext context, RemoteAddSettings settings)
    {
        if (settings.Verbose)
        {
            AnsiConsole.MarkupLine($"[dim]Adding remote...[/]");
        }
        AnsiConsole.MarkupLine($"Added remote [green]{settings.Name}[/] -> {settings.Url}");
        return 0;
    }
}
设置验证
public sealed class DeploySettings : CommandSettings
{
    [CommandArgument(0, "<environment>")]
    public string Environment { get; init; } = string.Empty;

    [CommandOption("--timeout")]
    [DefaultValue(30)]
    public int TimeoutSeconds { get; init; }

    public override ValidationResult Validate()
    {
        var validEnvs = new[] { "dev", "staging", "prod" };
        if (!validEnvs.Contains(Environment, StringComparer.OrdinalIgnoreCase))
        {
            return ValidationResult.Error(
                $"Environment must be one of: {string.Join(", ", validEnvs)}");
        }

        if (TimeoutSeconds <= 0)
        {
            return ValidationResult.Error("Timeout must be positive");
        }

        return ValidationResult.Success();
    }
}
异步命令
public sealed class FetchCommand : AsyncCommand<FetchSettings>
{
    public override async Task<int> ExecuteAsync(
        CommandContext context, FetchSettings settings)
    {
        await AnsiConsole.Status()
            .StartAsync("Fetching data...", async ctx =>
            {
                await Task.Delay(2000); // simulate work
            });

        AnsiConsole.MarkupLine("[green]Done![/]");
        return 0;
    }
}
使用 ITypeRegistrar 进行依赖注入
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;

// Set up DI container
var services = new ServiceCollection();
services.AddSingleton<IGreetingService, GreetingService>();
services.AddSingleton<IAnsiConsole>(AnsiConsole.Console);

var registrar = new TypeRegistrar(services);
var app = new CommandApp<GreetCommand>(registrar);
return app.Run(args);

// TypeRegistrar bridges Microsoft DI to Spectre.Console.Cli
public sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar
{
    public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider());

    public void Register(Type service, Type implementation)
        => services.AddSingleton(service, implementation);

    public void RegisterInstance(Type service, object implementation)
        => services.AddSingleton(service, implementation);

    public void RegisterLazy(Type service, Func<object> factory)
        => services.AddSingleton(service, _ => factory());
}

public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver
{
    public object? Resolve(Type? type)
        => type is null ? null : provider.GetService(type);
}

// Command receives services via constructor injection
public sealed class GreetCommand(IGreetingService greetingService) : Command<GreetSettings>
{
    public override int Execute(CommandContext context, GreetSettings settings)
    {
        var message = greetingService.GetGreeting(settings.Name);
        AnsiConsole.MarkupLine(message);
        return 0;
    }
}

可测试的控制台输出

Spectre.Console 提供 IAnsiConsole,用于实现可测试的输出,而不是直接写入真实控制台。

// Production: use AnsiConsole.Console (the real console)
IAnsiConsole console = AnsiConsole.Console;

// Testing: use a recording console
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
    Out = new AnsiConsoleOutput(new StringWriter())
});

// Use the abstraction instead of static AnsiConsole methods
console.MarkupLine("[green]Testable output[/]");
console.Write(new Table().AddColumn("Col").AddRow("Val"));

Agent 常见陷阱

  1. 不要在重定向的输出中使用 AnsiConsole.Markup*。 stdout 重定向时(管道传输到文件或另一个进程),ANSI 转义码会破坏输出。使用标记前,请检查 AnsiConsole.Profile.Capabilities.Ansi;或者使用配置合适的 IAnsiConsole。异步管道模式请参阅 [skill:dotnet-csharp-async-patterns]。
  2. 不要假设 CI 环境支持 ANSI。 CI 运行器(GitHub Actions、Azure Pipelines)可能不支持 ANSI 转义码。设置 TERM=dumb,或使用 AnsiConsole.Create() 并设置 ColorSystemSupport.NoColors,以确保输出适用于 CI。Spectre.Console 会自动检测功能,但显式配置可避免渲染不稳定。
  3. 不要混用 AnsiConsole 静态调用和 IAnsiConsole 实例调用。 静态的 AnsiConsole.Write() 始终面向真实控制台。使用 DI 注入 IAnsiConsole 时,请始终使用注入的实例。混用二者会导致输出重复或交错。
  4. 不要在 Live() 期间从后台线程修改可渲染对象。 实时显示不是线程安全的。只能在 Start/StartAsync 回调中修改目标可渲染对象,然后调用 ctx.Refresh()。并发修改会导致终端输出损坏。
  5. 不要在非交互环境中使用提示。 TextPrompt、SelectionPrompt 和 ConfirmationPrompt 会阻塞等待用户输入。在 CI 或自动化脚本中,请使用环境变量或命令行参数输入,而不要使用提示。提示前请检查 AnsiConsole.Profile.Capabilities.Interactive。
  6. 不要混淆 Spectre.Console.Cli 和 System.CommandLine。 它们是使用不同 API 的独立框架。Spectre.Console.Cli 使用带有 [CommandArgument]/[CommandOption] 特性的 CommandSettings 类,而 System.CommandLine 使用 Option<T> 和 Argument<T> 构建器模式。不要混用 API。有关 System.CommandLine,请参阅 [skill:dotnet-system-commandline]。
  7. 修改实时显示内容后,别忘了调用 ctx.Refresh()。 在 Live() 回调中更改表格、树形结构或面板后,只有调用 ctx.Refresh() 才会渲染更改。遗漏此调用会导致显示内容过时。
  8. 不要在没有回退方案的情况下硬编码颜色值。 色彩支持有限的终端会静默降低 TrueColor 值的显示效果。尽可能使用命名颜色(Color.Green),并使用环境变量 NO_COLOR=1 进行测试,以验证能否优雅降级。

前置条件

  • **NuGet 包:**用于丰富输出的 Spectre.Console 0.54.0;用于 CLI 框架的 Spectre.Console.Cli 0.53.1
  • **目标框架:**net8.0 或更高版本(也支持 netstandard2.0)
  • **终端:**任何支持 ANSI 转义序列的终端模拟器。为获得最佳体验(TrueColor、Unicode),建议使用 Windows Terminal、iTerm2 或现代 Linux 终端。在功能有限的终端上,控制台输出会优雅降级。
  • **将 DI 与 Spectre.Console.Cli 配合使用:**需要 Microsoft.Extensions.DependencyInjection 包,用于连接 ITypeRegistrar/ITypeResolver

参考资料

评论

支持 Markdown