SimpleExec: a small .NET wrapper for running external commands
🏃 A .NET library, developed by humans, for running external commands.
At a glance
- What is it?
- SimpleExec is an Apache 2.0 licensed .NET library that wraps System.Diagnostics.Process so that running an external command is one call, deliberately avoids the system shell, and turns a non-zero exit code into an exception you can either accept or override. The judgement is that the shell avoidance is the design decision that matters, and that the exit code handling is the part that makes it usable with the many tools that signal success in unusual ways.
- Who is it for?
- SimpleExec fits a .NET application that shells out regularly, especially one where the tool being called takes arguments that a shell would reinterpret, since the library passes them straight to the process, and one that needs to treat a non-zero exit code as data rather than a crash.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly C#, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Deliberately no shell in the path
SimpleExec is a .NET library, developed by humans, for running external commands, and its one architectural decision is stated in a single line: it intentionally does not invoke the system shell. What it wraps instead is System.Diagnostics.Process, the class the .NET runtime gives you for starting and controlling a child process. The difference is not academic. When a command line goes through a shell, the shell parses it, so quoting rules, path separators and metacharacters all become your problem, and a filename with a space in it becomes a bug. Calling the process directly means the argument list is passed to the program as written. The project also describes itself as developer friendly by intent rather than by feature count, and the platform support is .NET 8.0 and later, with the package published to NuGet under the name SimpleExec.
The quick start is two lines
The usage is a static import and a call:
using static SimpleExec.Command;
Run("foo", "arg1 arg2");Everything hangs off the Command class, and the static import is what lets the call read like a command. One behaviour is on by default, and it surprises people the first time they see it. The command is echoed to standard output for visibility, so the tool you ran appears in your own program's output before its output does. That is a deliberate choice for command line tools, where seeing what was launched helps when a wrapper misbehaves, and it can be turned off with the noEcho option, or given a prefix with echoPrefix if you want to interleave the echo with your own logging. The Read methods are the counterpart to this, and they return the output rather than writing it to the console, which is what you want when the parent process consumes the child's output.
Run, RunAsync and how arguments are passed
The Run family covers the common shapes. A command with no arguments, a command with a single argument string, and a command with an argument array are all supported, and each has an asynchronous twin:
Run("foo");
Run("foo", "arg1 arg2");
Run("foo", new[] { "arg1", "arg2" });
await RunAsync("foo");
await RunAsync("foo", "arg1 arg2");
await RunAsync("foo", new[] { "arg1", "arg2" });The two argument forms behave differently, and since there is no shell to normalise them the difference is yours to manage. The string form passes one string, and the array form passes separate arguments that the receiving program sees individually. For a program that expects a path with a space, the array form is the one that behaves the way you expect, because nothing splits the string. The asynchronous variants matter for anything that waits, and since the library is a thin wrapper over Process rather than a task scheduler of its own, the awaiting is direct.
Reading stdout and stderr as values
The Read family exists for the case where the parent needs the child's output rather than a side effect. ReadAsync returns a pair of values, standard output and standard error, and the same three argument shapes exist as for Run:
var (standardOutput1, standardError1) = await ReadAsync("foo");
var (standardOutput2, standardError2) = await ReadAsync("foo", "arg1 arg2");
var (standardOutput3, standardError3) = await ReadAsync("foo", new[] { "arg1", "arg2" });The optional arguments for ReadAsync add two things that Run does not have. There is an encoding parameter, so you can decode the child's output with the encoding it actually used rather than the one the console defaults to, and there is a standardInput parameter, so you can feed the child something on its standard input stream. Both matter in practice, since a tool that prompts for a password or a confirmation will otherwise hang.
The optional arguments are where the configuration lives
Both method families accept a working directory, a callback to configure the environment variables of the child process, and a cancellation token, and Run adds more. The full optional list for Run and RunAsync is:
string workingDirectory = "",
Action<IDictionary<string, string?>>? configureEnvironment = null,
IEnumerable<string> secrets = null,
Func<int, bool>? handleExitCode = null,
string? echoPrefix = null,
bool noEcho = false,
bool cancellationIgnoresProcessTree = false,
bool createNoWindow = false,
CancellationToken ct = default,Two of those deserve comment. The secrets parameter is a way to hand credentials to a child process without putting them in the environment dictionary, which is a different thing from what configureEnvironment does, since that callback edits the environment you are building. And cancellationIgnoresProcessTree controls whether cancelling the token kills the whole process tree or only the immediate child, which matters for the many tools that spawn helpers of their own and would otherwise be left running.
Exit codes as an exception, and how to opt out
The default behaviour is that a non-zero exit code becomes an exception. If the command exits non-zero, an ExitCodeException is thrown, and it carries an int ExitCode property with a message of the form:
$"The command exited with code {ExitCode}."For ReadAsync the exception is an ExitCodeReadException, which inherits from ExitCodeException and adds string StandardOutput and StandardError properties, so the captured output travels with the failure, and its message lays out the exit code, the standard output and the standard error. The interesting part is the escape hatch. Some programs return a non-zero exit code when they succeed, and the README names Robocopy, which returns a code below 8 on success and 8 or more on failure. Passing a delegate to handleExitCode that returns true suppresses the throwing of the exception, and returning false leaves the default behaviour in place:
Run("ROBOCOPY", "from to", handleExitCode: code => code < 8);The README also shows the more useful form of the same trick, where the delegate records the exit code on the way past so the caller can still inspect it afterwards.
The repository, the checks and the state of the project
The tree is that of a modern .NET repository, and it says the project cares about the boring parts. There is a Directory.Build.props and a Directory.Packages.props for shared build settings and central package versions, a global.json pinning the SDK, a SimpleExec.slnx solution file, a .globalconfig for the analysis tools, a .editorconfig, a .markdownlint.yml, a .cspell.json for spell checking, a CHANGELOG.md, a build.cmd, and the usual src, tests, targets, build, fixtures and assets directories. The CI badges in the README name four workflows, the main continuous integration, InferSharp for the nullable analysis, a lint job and a spell check job, so the code is checked on every push and the prose is checked too. The licence is Apache-2.0, with the LICENSE file in the root, which is the right choice for a library you intend to embed in a commercial application. The repository publishes no GitHub releases, so the package on NuGet is the release channel, the last push was on 2026-10-20, and the project is not archived, which for a library of this size says it is still being worked on rather than merely hosted.
Editorial conclusion
SimpleExec fits a .NET application that shells out regularly, especially one where the tool being called takes arguments that a shell would reinterpret, since the library passes them straight to the process, and one that needs to treat a non-zero exit code as data rather than a crash. It is the wrong choice if you need a pipeline with shell syntax, because by design there is no shell to interpret it, and it is a poor fit for a project still on .NET 7 or earlier, since the platform support starts at .NET 8.0. Before you adopt it, decide how your application should treat a non-zero exit code, because the default is to throw, and read the optional argument list once, since the working directory, environment and secret handling options are where the real configuration lives.
Frequently asked questions
How do I run an external command with SimpleExec?
Add the static import using static SimpleExec.Command, then call Run("foo", "arg1 arg2") or one of its overloads, with RunAsync available for the asynchronous versions. By default the command is echoed to standard output so you can see what was launched.
Does SimpleExec invoke the system shell?
No, and that is deliberate. It wraps System.Diagnostics.Process directly, so arguments reach the program as written instead of being parsed by a shell. Platform support is .NET 8.0 and later.
How do I stop SimpleExec throwing on a non-zero exit code?
Pass a handleExitCode delegate that returns true when it has dealt with the code. The README uses this for Robocopy, which succeeds with an exit code below 8, with Run("ROBOCOPY", "from to", handleExitCode: code => code < 8).
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/adamralph-simple-exec)Was this useful?