In this article, we will learn how to execute CLI applications in C# using a built-in class called Process
and using open-source libraries. We will cover how we can execute CLI, how we can check if the CLI execution was successful, and how we can react to the different outputs of CLI.
Throughout this article, we will execute different dotnet CLI commands from a C# code. Additionally, we will assume the dotnet is globally available on the host machine.
Execute CLI Applications With a Native Class
.NET provides a Process
class that enables us to configure, start and stop a process. There are different ways we can initiate a process and execute CLI applications in C#.
So, let’s check the first one:
Process proc = new(); proc.StartInfo.FileName = "<application or file>"; proc.StartInfo.Arguments = "<command arguments>"; proc.Start();
Here, we create an instance of Process
class, set the properties, and finally, call the start method.
Alternatively, we can use the static Start
method from Process
class:
Process.Start("<application or file>", "<command arguments>");
There are multiple overloads of the Start
method, each with different arguments. In this article, we will use the static Start
method with ProcessStartInfo
as an argument to start a process.
What is ProcessStartInfo?
Throughout this article, we will use the ProcessStartInfo
class. Therefore it is worth discussing what it is and what its commonly used properties mean. The ProcessStartInfo
class gives us more control in configuring a process and how it should start. Some of the commonly used properties of ProcessStartInfo
are:
FileName
: Is the target application or a file with default open action(such as batch or script file). This property can not be null.-
Arguments
: Is a string we want to pass to the target application we specified using theFileName
property. It is important to note that: If the arguments contain a delimiter (such as space), we should pass it in double quotes. Let’s take the dotnet command to create a new console project as an example:dotnet new console "Awesome Project"
Now, let’s inspect what this will look like when assigning it to
Arguments
:Arguments = "new console \"Awesome Project\""
CreateNoWindow
: enables us to specify whether the underlying process should start in a new window. The default value isfalse
.RedirectStandardOutput
andRedirectStandardError
are of boolean types. They enable us to specify if we want to redirect the underlying process’s standard and error outputs respectively.
Execute Command
In this section, we will execute the version command from dotnet CLI:
dotnet --version
This command prints the version of dotnet that is in use. Let’s take a look at how we can run this command from the code:
public class DotnetNativeWrapper { public async Task<Version> GetVersion() { ProcessStartInfo startInfo = new() { FileName = "dotnet", Arguments = "--version", CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; var proc = Process.Start(startInfo); ArgumentNullException.ThrowIfNull(proc); string output = proc.StandardOutput.ReadToEnd(); await proc.WaitForExitAsync(); return Version.Parse(output); } }
First, we start by creating an instance of ProcessStartInfo
: we set the value of FileName
to dotnet
, which is the CLI application we want to use and pass --version
as an argument. Next, we don’t want to create a new window to run the process; therefore, we set the CreateNoWindow
to true
. To retrieve both the standard and error CLI outputs, we set both RedirectStandardOutput
and RedirectStandardError
to true
.
At this point, we have the configuration ready. The next step is to start the process by calling the Start
method. The Start
method returns Process
instance if starting the process was successful. This does not mean command execution was successful. It simply indicates the initiation of the process resource with the configuration we provide.
If it is not successful, the Start
method returns null
. So, we execute the null check of the proc
variable.
Next, we use the StandardOutput
property to read the output of the CLI. It is essential to call the ReadToEnd
method before calling WaitForExitAsync
, because WaitForExitAsync
waits until the execution of the process is complete. Finally, we use the Parse
method to parse the output into Version
and return the result.
Check Exit Code
Usually, a zero exit code for CLI applications indicates successful execution of the command, and non-zero exit codes indicate an error. In this section, we will run an invalid dotnet command, and yes, you will be right to question the purpose of running an invalid command as there is no real-world use case. But we’ll do that to examine how we can capture the error logs and the exit code of the underlying process.
Let’s start by creating the RunInvalidCommand
method:
public async Task<(int exitCode, string? error)> RunInvalidCommand() { ProcessStartInfo startInfo = new() { FileName = "dotnet", Arguments = "invalid command", CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; var proc = Process.Start(startInfo); ArgumentNullException.ThrowIfNull(proc); string errorOutput = proc.StandardError.ReadToEnd(); await proc.WaitForExitAsync(); return (proc.ExitCode, errorOutput); }
The RunInvalidCommand
method runs an invalid command and returns the exit code and error output as a tuple.
Again, we begin by creating an instance of ProcessStartInfo
with a slight change on the Arguments
property. The value of the Arguments
is invalid because "invalid command"
is an invalid command in dotnet CLI. Next, we start the process and check the proc
is not null. Unlike the previous example, this time, we use the StandardError
property to read from the error stream. Finally, we wait until the process completes and return the exit code and error output as a tuple.
Let’s invoke the RunInvalidCommand
method:
static async Task RunInvalidCommandExample() { DotnetNativeWrapper native = new(); var (exitCode, errorOutput) = await native.RunInvalidCommand(); Console.WriteLine($"ExitCode: {exitCode}"); Console.WriteLine($"ErrorLogs: \n{errorOutput}"); }
Here we simply create an instance of the DotnetNativeWrapper
class and invoke RunInvalidCommand
.
Now, we can inspect the sample output:
ExitCode: 1 ErrorLogs: Could not execute because the specified command or file was not found. Possible reasons for this include: * You misspelled a built-in dotnet command. * You intended to execute a .NET program, but dotnet-invalid does not exist. * You intended to run a global tool, but a dotnet-prefixed executable with this name could not be found on the PATH.
Execute Script
Until now, we used the CLI application to execute different commands. But what if we want to execute a file? In this section, we will look at exactly how to do that.
Let’s start by creating an info.bat
file:
dotnet --info
Next, let’s create GetInfo
method that executes info.bat
:
public async Task<string> GetInfo() { ProcessStartInfo startInfo = new() { FileName = "cmd", Arguments = "/c \"info.bat\"", CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; var proc = Process.Start(startInfo); ArgumentNullException.ThrowIfNull(proc); string output = proc.StandardOutput.ReadToEnd(); await proc.WaitForExitAsync(); return output; }
This time we assign the FileName
property to cmd
which is the command prompt on windows. This means the batch file will run on the command prompt. Next, we assign the Arguments
property to a string /c
followed by the batch file’s name. The /c
flag in the command prompt means: run the subsequent string and terminate. There is another variant /k
which means: run the subsequent string and remain active.
Because we are using a command prompt, this example code only works on Windows. But we can make slight changes to make it work on other operating systems as well.
Let’s start by creating info.sh
file:
#!/usr/bin/env bash dotnet --info
Next, let’s assign new values to FileName
and Arguments
based on the underlying OS:
public async Task<Version> GetInfo() { var isWin = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var args = isWin ? "/c \"info.bat\"" : "-c \"./info.sh\""; var tool = isWin ? "cmd" : "bash"; ProcessStartInfo startInfo = new() { FileName = tool, Arguments = args, ... }; ... }
We first check if the OS is windows. If it is, we assign FileName
to cmd and Arguments
to "/c \"info.bat\""
which is like saying: use the command prompt and run the info.bat
batch file. But if the OS is not windows, we assign FileName
to bash and Arguments
to "-c \"./info.sh\""
which means: use bash and run the info.sh
script.
CLI Output as Event Stream
CLI applications can have different outputs that indicate the different states of the application. Also, we can have different events that correspond to the different state outputs. In this section, instead of waiting for the output from the underlying CLI process to the end, we will trigger an event whenever there are outputs from the CLI.
Let’s start by creating a delegate:
public delegate void OnChunkStreamHandler(string chunk);
Next, let’s create CliEventStreamer
class to read outputs from the CLI and trigger event:
public class CliEventStreamer { public CliEventStreamer(StreamReader streamReader) { ArgumentNullException.ThrowIfNull(streamReader); _streamReader = streamReader; Task.Factory.StartNew(Start); } public event OnChunkStreamHandler? OnChunkReceived; private readonly StreamReader _streamReader; private async Task Start() { int bufferSize = 8 * 1024; var buffer = new char[bufferSize]; while (true) { var chunkLength = await _streamReader.ReadAsync(buffer, 0, buffer.Length); if (chunkLength == 0) { break; } OnChunk(new string(buffer, 0, chunkLength)); } } private void OnChunk(string chunk) { OnChunkReceived?.Invoke(chunk); } }
The CliEventStreamer
class contains one constructor with StreamReader
as an argument. Inside the constructor, we start a new Task
to read outputs from the CLI process and trigger an event. The CliEventStreamer
class also contains one public event
namely OnChunkReceived
that we will use to invoke different methods based on the CLI output.
The Start
is where we read the CLI output from StreamReader
and trigger an event. First, we create char
array with an arbitrary size, and then we start an infinite loop that reads small chunks of the output from _streamReader
. If there is no output, we exit from the loop. Otherwise, we call OnChunk
method and pass the chunk string as an argument. The OnChunk
will in turn invoke the event handler.
The CliEventStreamer
class will handle reading from the stream and invoking the event handler.
Now, let’s add event properties into DotnetNativeWrapper
class:
public class DotnetNativeWrapper { public event OnChunkStreamHandler? OnStdOutput; public event OnChunkStreamHandler? OnStdErr; ... }
We can use the OnStdOutput
event property to add event handlers for standard output events and OnStdErr
to add event handlers for error output events.
Next, let’s add the ListProjects
method to DotnetNativeWrapper
class. The ListProjects
method lists the available project templates:
public async Task ListProjects() { ProcessStartInfo startInfo = new() { FileName = "dotnet", Arguments = "new list", CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; var proc = Process.Start(startInfo); ArgumentNullException.ThrowIfNull(proc); var stdOut = new CliEventStreamer(proc.StandardOutput); var stdErr = new CliEventStreamer(proc.StandardError); AttachStdOutEventHandler(stdOut); AttachStdErrEventHandler(stdErr); await proc.WaitForExitAsync(); } private void AttachStdOutEventHandler(CliEventStreamer stdOut) { if (OnStdOutput is not null) { stdOut.OnChunkReceived += OnStdOutput; } } private void AttachStdErrEventHandler(CliEventStreamer stdErr) { if (OnStdErr is not null) { stdErr.OnChunkReceived += OnStdErr; } }
Similar to the previous examples, we wire up the ProcessStartInfo
and start the process. This time, the difference is in handling the standard and error outputs. We create two instances of CliEventStreamer
one for streaming standard outputs and one for streaming error outputs, then we attach the event stream instances to the corresponding event handler: AttachStdOutEventHandler
for standard output and AttachStdErrEventHandler
for error output.
Execute CLI Applications With CliWrap
The native implementation works fine, but it gets complicated very quickly. To avoid that, we can use an alternative implementation with CliWrap. CliWrap is an open-source library that provides high-level API for running CLI applications. More information about CliWrap is available on GitHub.
In this section, we will try to understand how we can use CliWrap to execute different dotnet commands.
Let’s start by adding the NuGet package:
dotnet add package CliWrap
Execute Command
Let’s inspect how we can execute version commands using CliWrap:
public async Task<Version> GetVersion() { var result = await Cli.Wrap("dotnet") .WithArguments("--version") .ExecuteBufferedAsync(); return Version.Parse(result.StandardOutput); }
CliWrap comprises easy to understand and fluent configuration interface. Inside the GetVersion
method the call to the static Wrap
method defines the executable or file(batch or script) we want to execute. As its name suggests WithArguments
will pass the arguments. Next, ExecuteBufferedAsync
executes the CLI and returns a result that is of type BufferedCommandResult
that contains both the standard and error outputs. Finally, we parse the version information from the standard output and return the result.
Check Exit Code
The default behavior in CliWrap for a non-zero exit code of the underlying process is to throw an exception, as a non-zero exit code is usually an indicator of an error. CliWrap also provides a way to suppress this default behavior. Let’s inspect how we can do that by running invalid dotnet command:
public async Task<(int exitCode, string? error)> RunInvalidCommand() { var result = await Cli.Wrap("dotnet") .WithArguments("invalid command") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(); return (result.ExitCode, result.StandardError); }
In this example code, we are passing an invalid argument and configuring CliWrap not to throw an exception by calling WithValidation
method and passing CommandResultValidation.None
as an argument. Next, we return the non-zero exit code and error output as a tuple.
CLI Event Stream
Last but not least, we will look at how we can stream the different CLI process state changes and outputs as an event using CliWrap. For this example, we will use the dotnet new list
command. Let’s start by declaring delegates:
public delegate void OnStart(int processId); public delegate void OnExit(int exitCode); public delegate void OnTextStreamHandler(string text);
Next, let’s add new event properties into DotnetCliWrap
:
public class DotnetCliWrap { public event OnTextStreamHandler? OnStdOutput; public event OnTextStreamHandler? OnStdErr; public event OnStart? OnStart; public event OnExit? OnExit; ... }
Now that we have the necessary event properties let’s implement the method to execute the new list
command:Â
public async Task ListProjects() { var cmd = Cli.Wrap("dotnet").WithArguments("new list"); await foreach (var cmdEvent in cmd.ListenAsync()) { switch (cmdEvent) { case StartedCommandEvent started: OnStart?.Invoke(started.ProcessId); break; case StandardOutputCommandEvent stdOut: OnStdOutput?.Invoke(stdOut.Text); break; case StandardErrorCommandEvent stdErr: OnStdErr?.Invoke(stdErr.Text); break; case ExitedCommandEvent exited: OnExit?.Invoke(exited.ExitCode); break; } } }
We start by creating the command instance, then inside the async foreach
loop we wait for events. Next, based on the concrete type of the event, we invoke the corresponding handler in the switch case.
As we saw in the few examples, CliWarp provides a cleaner way to execute and monitor CLI processes. Moreover, CliWrap comprises several useful features that are beyond the scope of this article. Learn more about those amazing features on GitHub.
Conclusion
In this article, we learned how to execute CLI applications, check if the execution of underlying CLI applications was successful, and stream the state and outputs of the CLI process as an event. We also saw two approaches to execute CLI applications: first, using the native Process
class, and second, using an open-source library called CliWrap.