AgentSkillsCN

cliwrap

在.NET中使用CliWrap从外部命令行进程执行时使用。涵盖流畅的命令构建、输出捕获、管道传输、流式处理、取消操作,以及环境变量配置。 适用范围:从.NET代码中执行CLI工具、捕获stdout/stderr输出、在进程间进行管道传输、流式传输大型命令输出、从C#应用中运行git/docker/npm。 不适用范围:构建CLI参数解析器(使用commandline-cheatsheet或spectre-console)、渲染终端UI(使用spectre-console)、在进程中运行代码(使用Task.Run)、简单的Process.Start而无需高级管道传输。

SKILL.md
--- frontmatter
name: cliwrap
description: |
  Use when executing external command-line processes from .NET with CliWrap. Covers fluent command building, output capturing, piping, streaming, cancellation, and environment variable configuration.
  USE FOR: executing CLI tools from .NET code, capturing stdout/stderr output, piping between processes, streaming large command output, running git/docker/npm from C# applications
  DO NOT USE FOR: building CLI argument parsers (use commandline-cheatsheet or spectre-console), rendering terminal UI (use spectre-console), running in-process code (use Task.Run), simple Process.Start without advanced piping
license: MIT
metadata:
  displayName: "CliWrap"
  author: "Tyler-R-Kendrick"
  version: "1.0.0"
compatibility: claude, copilot, cursor

CliWrap

Overview

CliWrap is a library for interacting with external command-line processes in .NET. It provides a fluent API for building commands, capturing output, piping between processes, streaming stdout/stderr in real time, and handling cancellation and timeouts. CliWrap replaces direct use of System.Diagnostics.Process with a safer, more composable, and async-first API.

NuGet Packages

bash
dotnet add package CliWrap

Basic Command Execution

csharp
using CliWrap;

// Simple execution (fire and forget with exit code check)
var result = await Cli.Wrap("dotnet")
    .WithArguments("--version")
    .ExecuteAsync();

Console.WriteLine($"Exit code: {result.ExitCode}");
Console.WriteLine($"Started: {result.StartTime}");
Console.WriteLine($"Exited: {result.ExitTime}");
Console.WriteLine($"Duration: {result.RunTime}");

Buffered Output (Capturing stdout/stderr)

csharp
using CliWrap;
using CliWrap.Buffered;

var result = await Cli.Wrap("dotnet")
    .WithArguments(["build", "--configuration", "Release"])
    .WithWorkingDirectory("/src/MyProject")
    .ExecuteBufferedAsync();

Console.WriteLine($"STDOUT:\n{result.StandardOutput}");
Console.WriteLine($"STDERR:\n{result.StandardError}");
Console.WriteLine($"Exit code: {result.ExitCode}");

Building Arguments

csharp
using CliWrap;

// Array-based arguments (automatically handles escaping)
var result = await Cli.Wrap("git")
    .WithArguments(["commit", "-m", "Fix: handle spaces in file names"])
    .ExecuteBufferedAsync();

// Builder-based arguments for complex commands
var result2 = await Cli.Wrap("docker")
    .WithArguments(args => args
        .Add("run")
        .Add("--rm")
        .Add("-v").Add("/host/path:/container/path")
        .Add("-e").Add("CONNECTION_STRING=Server=localhost;Database=mydb")
        .Add("--name").Add("my-container")
        .Add("mcr.microsoft.com/dotnet/sdk:8.0"))
    .ExecuteBufferedAsync();

Streaming Output in Real Time

csharp
using CliWrap;
using CliWrap.EventStream;

var cmd = Cli.Wrap("dotnet")
    .WithArguments(["test", "--verbosity", "normal"])
    .WithWorkingDirectory("/src/MyProject");

await foreach (var cmdEvent in cmd.ListenAsync())
{
    switch (cmdEvent)
    {
        case StartedCommandEvent started:
            Console.WriteLine($"Process started; PID: {started.ProcessId}");
            break;
        case StandardOutputCommandEvent stdOut:
            Console.WriteLine($"[OUT] {stdOut.Text}");
            break;
        case StandardErrorCommandEvent stdErr:
            Console.WriteLine($"[ERR] {stdErr.Text}");
            break;
        case ExitedCommandEvent exited:
            Console.WriteLine($"Process exited; Code: {exited.ExitCode}");
            break;
    }
}

Piping stdout/stderr

csharp
using CliWrap;

// Pipe to delegates
await Cli.Wrap("dotnet")
    .WithArguments(["build"])
    .WithStandardOutputPipe(PipeTarget.ToDelegate(line =>
        Console.WriteLine($"[BUILD] {line}")))
    .WithStandardErrorPipe(PipeTarget.ToDelegate(line =>
        Console.Error.WriteLine($"[ERROR] {line}")))
    .ExecuteAsync();

// Pipe to file
await Cli.Wrap("dotnet")
    .WithArguments(["test", "--logger", "trx"])
    .WithStandardOutputPipe(PipeTarget.ToFile("test-output.log"))
    .ExecuteAsync();

// Pipe to StringBuilder
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();

await Cli.Wrap("git")
    .WithArguments(["log", "--oneline", "-20"])
    .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer))
    .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
    .ExecuteAsync();

Console.WriteLine(stdOutBuffer.ToString());

// Pipe to Stream
using var memoryStream = new MemoryStream();
await Cli.Wrap("curl")
    .WithArguments(["-s", "https://api.example.com/data"])
    .WithStandardOutputPipe(PipeTarget.ToStream(memoryStream))
    .ExecuteAsync();

// Pipe to multiple targets simultaneously
await Cli.Wrap("dotnet")
    .WithArguments(["build"])
    .WithStandardOutputPipe(PipeTarget.Merge(
        PipeTarget.ToDelegate(Console.WriteLine),
        PipeTarget.ToFile("build.log")))
    .ExecuteAsync();

Piping Between Processes

csharp
using CliWrap;

// Pipe stdout of one command into stdin of another (like shell pipes)
var result = await (
    Cli.Wrap("dotnet")
        .WithArguments(["tool", "list", "--global"]) |
    Cli.Wrap("findstr")
        .WithArguments(["dotnet-ef"])
).ExecuteBufferedAsync();

Console.WriteLine(result.StandardOutput);

Providing stdin Input

csharp
using CliWrap;

// Pipe string input
var result = await Cli.Wrap("dotnet")
    .WithArguments(["script", "eval"])
    .WithStandardInputPipe(PipeSource.FromString("Console.WriteLine(1 + 2);"))
    .ExecuteBufferedAsync();

// Pipe from file
await Cli.Wrap("python")
    .WithArguments(["script.py"])
    .WithStandardInputPipe(PipeSource.FromFile("input.txt"))
    .ExecuteAsync();

// Pipe from stream
using var inputStream = new MemoryStream("Hello, World!"u8.ToArray());
await Cli.Wrap("cat")
    .WithStandardInputPipe(PipeSource.FromStream(inputStream))
    .ExecuteAsync();

Cancellation and Timeouts

csharp
using CliWrap;

// Cancellation token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

try
{
    await Cli.Wrap("dotnet")
        .WithArguments(["test"])
        .ExecuteAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Command was cancelled or timed out");
}

// Graceful shutdown: sends SIGINT first, then SIGKILL after timeout
using var cts2 = new CancellationTokenSource();
cts2.CancelAfter(TimeSpan.FromMinutes(5));

try
{
    await Cli.Wrap("long-running-process")
        .ExecuteAsync(
            forcefulCancellationToken: cts2.Token,
            gracefulCancellationToken: cts2.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Process was terminated");
}

Environment Variables and Configuration

csharp
using CliWrap;

var result = await Cli.Wrap("dotnet")
    .WithArguments(["run"])
    .WithWorkingDirectory("/src/MyApp")
    .WithEnvironmentVariables(env => env
        .Set("ASPNETCORE_ENVIRONMENT", "Staging")
        .Set("ConnectionStrings__Default", "Server=localhost;Database=testdb")
        .Set("DOTNET_CLI_TELEMETRY_OPTOUT", "1"))
    .WithValidation(CommandResultValidation.ZeroExitCode)
    .ExecuteAsync();

Error Handling

csharp
using CliWrap;
using CliWrap.Exceptions;

try
{
    await Cli.Wrap("dotnet")
        .WithArguments(["build"])
        .WithValidation(CommandResultValidation.ZeroExitCode) // Default behavior
        .ExecuteBufferedAsync();
}
catch (CommandExecutionException ex)
{
    Console.Error.WriteLine($"Command failed with exit code: {ex.ExitCode}");
    Console.Error.WriteLine($"Command: {ex.Command}");
}

// Disable validation to handle non-zero exit codes manually
var result = await Cli.Wrap("git")
    .WithArguments(["diff", "--exit-code"])
    .WithValidation(CommandResultValidation.None)
    .ExecuteBufferedAsync();

if (result.ExitCode != 0)
{
    Console.WriteLine("Working directory has changes:");
    Console.WriteLine(result.StandardOutput);
}

Wrapping Common CLI Tools

csharp
public class GitClient
{
    private readonly string _workingDirectory;

    public GitClient(string workingDirectory) =>
        _workingDirectory = workingDirectory;

    private Command BaseCommand => Cli.Wrap("git")
        .WithWorkingDirectory(_workingDirectory)
        .WithValidation(CommandResultValidation.ZeroExitCode);

    public async Task<string> GetCurrentBranchAsync(CancellationToken ct = default)
    {
        var result = await BaseCommand
            .WithArguments(["branch", "--show-current"])
            .ExecuteBufferedAsync(ct);
        return result.StandardOutput.Trim();
    }

    public async Task<string> GetStatusAsync(CancellationToken ct = default)
    {
        var result = await BaseCommand
            .WithArguments(["status", "--porcelain"])
            .ExecuteBufferedAsync(ct);
        return result.StandardOutput;
    }

    public async Task CommitAsync(string message, CancellationToken ct = default)
    {
        await BaseCommand
            .WithArguments(["add", "-A"])
            .ExecuteAsync(ct);

        await BaseCommand
            .WithArguments(["commit", "-m", message])
            .ExecuteAsync(ct);
    }

    public async Task<IReadOnlyList<string>> GetLogAsync(
        int count = 10, CancellationToken ct = default)
    {
        var result = await BaseCommand
            .WithArguments(["log", $"--oneline", $"-{count}"])
            .ExecuteBufferedAsync(ct);

        return result.StandardOutput
            .Split('\n', StringSplitOptions.RemoveEmptyEntries);
    }
}

Best Practices

  • Use WithArguments(string[]) (array overload) instead of WithArguments(string) (raw string) to get automatic escaping of spaces, quotes, and special characters in argument values.
  • Use ExecuteBufferedAsync only for commands with bounded output; for commands that may produce megabytes of output (e.g., dotnet test --verbosity detailed), use ListenAsync or pipe to a Stream to avoid memory pressure.
  • Set WithValidation(CommandResultValidation.ZeroExitCode) explicitly (or rely on the default) to get CommandExecutionException on failure rather than silently ignoring non-zero exit codes.
  • Always pass CancellationToken to ExecuteAsync and ExecuteBufferedAsync; use CancellationTokenSource.CancelAfter to enforce timeouts on long-running commands.
  • Use WithWorkingDirectory to set the process working directory explicitly rather than relying on the host application's current directory, which may differ between development and deployment.
  • Create typed wrapper classes (e.g., GitClient, DockerClient) that expose domain-specific methods using a shared BaseCommand property to centralize working directory, validation, and environment configuration.
  • Use PipeTarget.Merge to send output to both a log file and the console simultaneously during build/deploy scripts where you need real-time feedback and a persistent record.
  • Handle CommandExecutionException specifically (not just Exception) to extract the exit code and command details for structured error reporting and logging.
  • Use the | pipe operator between Command instances (cmdA | cmdB) for shell-like piping instead of capturing intermediate output in memory and feeding it to the next process.
  • Set environment variables with WithEnvironmentVariables rather than modifying Environment.SetEnvironmentVariable globally, which affects all threads and is not isolated per command.