关于 dotnet-spectre-console
面向 .NET 开发者的 Spectre.Console 使用技能,涵盖丰富的终端输出与 Spectre.Console.Cli 命令解析。适用于构建交互式控制台界面和结构化命令行应用,并说明可测试输出等实践。
主要功能
- 01样式文本、表格、树与面板输出
- 02显示进度条、状态指示与动态内容
- 03提供文本、确认和选择提示
- 04使用 Spectre.Console.Cli 组织命令层级
- 05支持类型化设置、验证与自动帮助
- 06通过 IAnsiConsole 测试控制台输出
适用场景
- 01为命令行程序呈现表格和进度
- 02构建带分支命令的 CLI
- 03收集用户输入并验证设置
- 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 常见陷阱
- 不要在重定向的输出中使用
AnsiConsole.Markup*。 stdout 重定向时(管道传输到文件或另一个进程),ANSI 转义码会破坏输出。使用标记前,请检查AnsiConsole.Profile.Capabilities.Ansi;或者使用配置合适的IAnsiConsole。异步管道模式请参阅 [skill:dotnet-csharp-async-patterns]。 - 不要假设 CI 环境支持 ANSI。 CI 运行器(GitHub Actions、Azure Pipelines)可能不支持 ANSI 转义码。设置
TERM=dumb,或使用AnsiConsole.Create()并设置ColorSystemSupport.NoColors,以确保输出适用于 CI。Spectre.Console 会自动检测功能,但显式配置可避免渲染不稳定。 - 不要混用
AnsiConsole静态调用和IAnsiConsole实例调用。 静态的AnsiConsole.Write()始终面向真实控制台。使用 DI 注入IAnsiConsole时,请始终使用注入的实例。混用二者会导致输出重复或交错。 - 不要在
Live()期间从后台线程修改可渲染对象。 实时显示不是线程安全的。只能在Start/StartAsync回调中修改目标可渲染对象,然后调用ctx.Refresh()。并发修改会导致终端输出损坏。 - 不要在非交互环境中使用提示。
TextPrompt、SelectionPrompt和ConfirmationPrompt会阻塞等待用户输入。在 CI 或自动化脚本中,请使用环境变量或命令行参数输入,而不要使用提示。提示前请检查AnsiConsole.Profile.Capabilities.Interactive。 - 不要混淆 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]。 - 修改实时显示内容后,别忘了调用
ctx.Refresh()。 在Live()回调中更改表格、树形结构或面板后,只有调用ctx.Refresh()才会渲染更改。遗漏此调用会导致显示内容过时。 - 不要在没有回退方案的情况下硬编码颜色值。 色彩支持有限的终端会静默降低 TrueColor 值的显示效果。尽可能使用命名颜色(
Color.Green),并使用环境变量NO_COLOR=1进行测试,以验证能否优雅降级。
前置条件
- **NuGet 包:**用于丰富输出的
Spectre.Console0.54.0;用于 CLI 框架的Spectre.Console.Cli0.53.1 - **目标框架:**net8.0 或更高版本(也支持 netstandard2.0)
- **终端:**任何支持 ANSI 转义序列的终端模拟器。为获得最佳体验(TrueColor、Unicode),建议使用 Windows Terminal、iTerm2 或现代 Linux 终端。在功能有限的终端上,控制台输出会优雅降级。
- **将 DI 与 Spectre.Console.Cli 配合使用:**需要
Microsoft.Extensions.DependencyInjection包,用于连接ITypeRegistrar/ITypeResolver
参考资料
- Spectre.Console GitHub -- 源代码、问题和示例
- Spectre.Console 文档 -- 官方指南和 API 参考
- Spectre.Console NuGet -- 包下载和版本历史
- Spectre.Console.Cli NuGet -- CLI 框架包
- Spectre.Console 示例 -- 官方示例项目