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.

To download the source code for this article, you can visit our GitHub repository.

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#.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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 the FileName 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 is false.
  • RedirectStandardOutput and RedirectStandardError 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 CliEventStreamerclass 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.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!