A .NET library for executing external commands and handling their output through delegates, with synchronous and asynchronous APIs, cancellation, and control over the spawned process.
ktsu.RunCommand runs an external command and hands you its output as it arrives, instead of making you assemble Process, ProcessStartInfo, redirected streams and exit-code plumbing yourself. Output is delivered through delegates — either as raw chunks exactly as the process emits them, or buffered into complete lines — and every method returns the process exit code.
Arguments are passed as a vector rather than as one string, so a path containing spaces needs no manual quoting and cannot be mis-split. The process itself can be shaped through a working directory and an environment variable overlay, run elevated on Windows, and terminated along with its children through a cancellation token.
- Delegate-based output: Receive standard output and standard error through
Action<string>delegates as the process produces them, rather than waiting for it to exit. - Raw or line-buffered:
OutputHandlerdelivers undelimited chunks exactly as they arrive;LineOutputHandlerbuffers across chunks and raises one call per complete line. - Synchronous and asynchronous: Every operation is available as both
ExecuteandExecuteAsync, with the asynchronous implementation as the single source of truth. - Quote-free arguments: Pass the executable and each argument separately, so spaces in paths and arguments are handled by the platform rather than by string concatenation.
- Working directory: Start the process in a specific directory without mutating the process-global current directory.
- Environment variables: Apply an overlay over the inherited environment for a single call, adding, overriding, or removing individual variables.
- Cancellation: A signalled
CancellationTokenterminates the process and always surfaces as anOperationCanceledException, never as a synthetic exit code. - Windows elevation: Launch through the
runasverb for a UAC-elevated process. - Custom encoding: Decode the output streams with any
Encoding; defaults to UTF-8. - Broad target support: .NET Standard 2.0 and 2.1 through .NET 10.
Install-Package ktsu.RunCommanddotnet add package ktsu.RunCommand<PackageReference Include="ktsu.RunCommand" Version="1.5.0" />Pass the executable and its arguments separately. All methods return the process exit code:
using ktsu.RunCommand;
class Program
{
static void Main()
{
int exitCode = RunCommand.Execute("dotnet", ["--version"]);
if (exitCode == 0)
{
Console.WriteLine("Command executed successfully!");
}
else
{
Console.WriteLine($"Command failed with exit code: {exitCode}");
}
}
}To handle the output of the command, provide delegates to the OutputHandler class:
using ktsu.RunCommand;
class Program
{
static void Main()
{
int exitCode = RunCommand.Execute(
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new(
onStandardOutput: Console.Write,
onStandardError: Console.Write
)
);
Console.WriteLine($"Process exited with code: {exitCode}");
}
}NOTE: When using the default
OutputHandler, the delegates receive undelimited chunks of output. This gives you exactly what the command produces, including whitespace and non-printable characters, to handle as you see fit.
To handle the output one line at a time, use the LineOutputHandler class:
using ktsu.RunCommand;
class Program
{
static void Main()
{
int exitCode = RunCommand.Execute(
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new LineOutputHandler(
onStandardOutput: line => Console.WriteLine($"Output: {line}"),
onStandardError: line => Console.WriteLine($"Error: {line}")
)
);
Console.WriteLine($"Process exited with code: {exitCode}");
}
}All of the above examples can be run asynchronously with ExecuteAsync:
using ktsu.RunCommand;
class Program
{
static async Task Main()
{
int exitCode = await RunCommand.ExecuteAsync("dotnet", ["--version"]);
Console.WriteLine($"Process exited with code: {exitCode}");
}
}Passing a CancellationToken terminates the process when the token is signalled:
using ktsu.RunCommand;
class Program
{
static async Task Main()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30));
try
{
int exitCode = await RunCommand.ExecuteAsync(
fileName: "dotnet",
arguments: ["build"],
outputHandler: new LineOutputHandler(onStandardOutput: Console.WriteLine),
cancellationToken: cancellation.Token);
Console.WriteLine($"Process exited with code: {exitCode}");
}
catch (OperationCanceledException)
{
Console.WriteLine("The command was cancelled.");
}
}
}A cancelled call always throws OperationCanceledException — it never returns the killed process's exit code — so cancellation cannot be mistaken for a genuine failure of the command.
On .NET Core 3.0 and later the entire process tree is terminated. On .NET Standard 2.0 and 2.1 only the process itself can be terminated, so any grandchildren it spawned are left running.
CommandOptions shapes the process a command runs in. Pass it alongside an executable and its arguments:
using ktsu.RunCommand;
using ktsu.Semantics.Paths;
class Program
{
static async Task Main()
{
int exitCode = await RunCommand.ExecuteAsync(
fileName: "git",
arguments: ["status", "--short"],
outputHandler: new LineOutputHandler(onStandardOutput: Console.WriteLine),
options: new()
{
WorkingDirectory = AbsoluteDirectoryPath.Create(@"C:\repos\my project"),
EnvironmentVariables = new Dictionary<string, string?>
{
["GIT_TERMINAL_PROMPT"] = "0",
["LC_ALL"] = "C",
},
});
Console.WriteLine($"Process exited with code: {exitCode}");
}
}CommandOptions.Elevation carries the privilege level too, so a single options object replaces the separate Elevation argument.
Without a WorkingDirectory the process inherits the current directory of the calling process, which is what commands did before this option existed.
The type is AbsoluteDirectoryPath rather than a string on purpose. A relative directory would have to be resolved against the caller's current directory — the process-global state this option exists to avoid depending on, since it is shared by every thread and races with concurrent calls.
EnvironmentVariables is an overlay on the inherited environment, not a replacement: a name you do not list keeps whatever the calling process had. A null value removes a variable, which is how you unset something the parent had set:
EnvironmentVariables = new Dictionary<string, string?>
{
["GIT_DIR"] = null,
}Environment variables are the only control surface some tools expose, so this covers behaviour with no command-line equivalent — GIT_TERMINAL_PROMPT=0 to make an authenticating git fetch fail rather than block forever on a prompt no terminal will answer, GIT_ASKPASS/SSH_ASKPASS to supply credentials without putting them on a command line where any process listing can read them, and LC_ALL=C to force stable, machine-parseable output rather than whatever the host locale produces.
NOTE:
EnvironmentVariablescannot be combined withElevation.Elevatedon Windows. Elevation requiresUseShellExecute, which offers nowhere to pass an environment, so the call throwsArgumentExceptionrather than silently dropping the variables.
To run a command with elevated privileges, set Elevation.Elevated. On Windows this launches the process with the runas verb, which triggers a UAC prompt:
using ktsu.RunCommand;
class Program
{
static void Main()
{
int exitCode = RunCommand.Execute(
fileName: "powershell",
arguments: ["-Command", "Get-Service"],
outputHandler: new(),
options: new() { Elevation = Elevation.Elevated });
Console.WriteLine($"Process exited with code: {exitCode}");
}
}NOTE: Output redirection is incompatible with
runas, so anOutputHandlerpassed alongsideElevation.Elevatedwill not be invoked. You still get the process exit code.
On non-Windows platforms Elevation.Elevated is a no-op — prefix your command with sudo yourself if you need elevation there.
By default the library decodes the output streams as UTF-8. To use a different encoding, specify it in the OutputHandler or LineOutputHandler constructor:
using System.Text;
using ktsu.RunCommand;
class Program
{
static void Main()
{
int exitCode = RunCommand.Execute(
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new(
onStandardOutput: Console.Write,
onStandardError: Console.Write,
encoding: Encoding.ASCII
)
);
}
}The overloads taking one command string are obsolete. They separate the executable from its arguments by splitting on the first space, which cannot represent an executable path that itself contains a space — on Windows that includes anything under C:\Program Files\:
// Obsolete, and broken: splits into "C:\Program" plus "Files\Git\bin\git.exe --version"
await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe --version");
// Correct
await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe", ["--version"]);Quoting does not rescue it, because the split happens before any quote handling. The string form is inherently ambiguous — no parse handles every combination of spaces and quotes without adopting a shell's full grammar — so rather than grow a half-grammar that moves the surprise elsewhere, these overloads are deprecated in favour of the argument-list ones, which have no such ambiguity because the executable is passed separately.
Migration is mechanical: split the string yourself at the boundaries you meant.
| Obsolete | Replacement |
|---|---|
Execute(command) |
Execute(fileName, arguments) |
Execute(command, outputHandler) |
Execute(fileName, arguments, outputHandler) |
Execute(command, elevation) |
Execute(fileName, arguments, outputHandler, options) |
ExecuteAsync(command) |
ExecuteAsync(fileName, arguments) |
ExecuteAsync(command, outputHandler) |
ExecuteAsync(fileName, arguments, outputHandler) |
ExecuteAsync(command, cancellationToken) |
ExecuteAsync(fileName, arguments, outputHandler, cancellationToken) |
ExecuteAsync(command, outputHandler, elevation, cancellationToken) |
ExecuteAsync(fileName, arguments, outputHandler, options, cancellationToken) |
Static class providing the command execution API. Every method returns the process exit code.
| Name | Return Type | Description |
|---|---|---|
Execute(string fileName, IEnumerable<string> arguments) |
int |
Executes a command synchronously. |
Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler) |
int |
Executes a command synchronously with custom output handling. |
Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options) |
int |
Executes a command synchronously with the given process options. |
ExecuteAsync(string fileName, IEnumerable<string> arguments) |
Task<int> |
Executes a command asynchronously. |
ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler) |
Task<int> |
Executes a command asynchronously with custom output handling. |
ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CancellationToken cancellationToken) |
Task<int> |
As above, terminating the process and its children if the token is signalled. |
ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken) |
Task<int> |
As above, at the given elevation level. |
ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options) |
Task<int> |
Executes a command asynchronously with the given process options. |
ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken) |
Task<int> |
As above, terminating the process and its children if the token is signalled. |
The overloads taking a single command string — four Execute and seven ExecuteAsync — are obsolete. See Deprecated: Single Command Strings for the migration table.
Record describing how to shape the process a command runs in. Every member defaults to the behaviour commands had before the type existed, so an instance with nothing set is equivalent to not passing one at all.
| Name | Type | Description |
|---|---|---|
WorkingDirectory |
AbsoluteDirectoryPath? |
The directory the process starts in, or null to inherit the caller's current directory. |
EnvironmentVariables |
IReadOnlyDictionary<string, string?>? |
Variables applied over the inherited environment, or null to inherit it unchanged. A null value removes a variable. |
Elevation |
Elevation |
The privilege level under which to run the command. Defaults to Elevation.Default. |
Processes output in raw, undelimited chunks as they arrive from the process.
| Name | Description |
|---|---|
OutputHandler(Action<string>? onStandardOutput = null, Action<string>? onStandardError = null, Encoding? encoding = null) |
Creates a handler with delegates for the output and error streams. encoding defaults to UTF-8. |
| Name | Type | Description |
|---|---|---|
Encoding |
Encoding |
The encoding used to decode the process's output streams. |
Inherits from OutputHandler and buffers incoming chunks, invoking the delegates once per complete line. Incomplete trailing data is held until the rest of the line arrives.
| Name | Description |
|---|---|
LineOutputHandler(Action<string>? onStandardOutput = null, Action<string>? onStandardError = null, Encoding? encoding = null) |
Creates a line-buffering handler with delegates for the output and error streams. |
Enum specifying the privilege level under which a command runs.
| Name | Description |
|---|---|
Default |
Run with the current process's privileges. Standard output and standard error are captured. |
Elevated |
On Windows, launch through the runas verb, prompting for UAC consent; output is not captured. No effect on non-Windows platforms. |
Contributions are welcome! Feel free to open issues or submit pull requests.
This project is licensed under the MIT License. See the LICENSE.md file for details.