diff --git a/Directory.Packages.props b/Directory.Packages.props
index c8f6ea5..9af2ba3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,6 +3,8 @@
true
+
+
diff --git a/README.md b/README.md
index 71738d6..c456b40 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,38 @@ class Program
On non-Windows platforms `Elevation.Elevated` is a no-op — prefix your command with `sudo` yourself if you need elevation there.
+## Process Options
+
+`CommandOptions` shapes the process a command runs in. Pass it alongside an executable and its arguments:
+
+```csharp
+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"),
+ });
+
+ Console.WriteLine($"Process exited with code: {exitCode}");
+ }
+}
+```
+
+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.
+
+`CommandOptions.Elevation` carries the privilege level, so a single options object replaces the separate `Elevation` argument.
+
## Encoding
By default, the library uses the UTF-8 encoding for the input and output streams. If you need to use a different encoding, you can specify it in the `OutputHandler` or `LineOutputHandler` constructor:
@@ -180,6 +212,14 @@ class Program
- `ExecuteAsync(string command, OutputHandler outputHandler)`: Executes a command asynchronously with custom output handling and returns a task with the process exit code.
- `ExecuteAsync(string command, Elevation elevation)`: Executes a command asynchronously at the given elevation level.
- `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)`: Executes a command asynchronously with custom output handling at the given elevation level.
+- `Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)`: Executes a command synchronously with the given process options, passing arguments individually so no manual quoting is required.
+- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)`: The asynchronous equivalent.
+- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled.
+
+### CommandOptions Record
+
+- `WorkingDirectory`: An `AbsoluteDirectoryPath` naming the directory the process starts in, or `null` to inherit the caller's current directory.
+- `Elevation`: The privilege level under which to run the command. Defaults to `Elevation.Default`.
### Elevation Enum
diff --git a/RunCommand.Test/RunCommand.Test.csproj b/RunCommand.Test/RunCommand.Test.csproj
index 89b68ce..554d565 100644
--- a/RunCommand.Test/RunCommand.Test.csproj
+++ b/RunCommand.Test/RunCommand.Test.csproj
@@ -9,6 +9,8 @@
+
+
diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs
index 91f1721..42093c2 100644
--- a/RunCommand.Test/RunCommandTests.cs
+++ b/RunCommand.Test/RunCommandTests.cs
@@ -4,6 +4,7 @@ namespace ktsu.RunCommand.Test;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using ktsu.Semantics.Paths;
[TestClass]
public class RunCommandTests
@@ -310,6 +311,18 @@ private static (string FileName, string[] Arguments) GetReadFileCommand(string p
? ("certutil", ["-hashfile", path, "MD5"])
: ("cat", [path]);
+ ///
+ /// Returns a command that prints the directory its process was started in.
+ ///
+ private static (string FileName, string[] Arguments) GetPrintWorkingDirectoryCommand() =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ("cmd", ["/c", "cd"])
+ : ("pwd", []);
+
+ // The caller name keeps each test on its own directory, since tests run in parallel.
+ private static string CreateDirectoryForTest([CallerMemberName] string caller = "") =>
+ Directory.CreateDirectory(Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)} {caller}")).FullName;
+
///
/// Returns a command that runs for long enough to be cancelled mid-flight.
///
@@ -413,4 +426,55 @@ await Assert.ThrowsAsync(
$"Attempt {attempt} returned an exit code instead of throwing.").ConfigureAwait(false);
}
}
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldStartTheProcessInTheGivenWorkingDirectory()
+ {
+ string directory = CreateDirectoryForTest();
+ (string fileName, string[] arguments) = GetPrintWorkingDirectoryCommand();
+ List output = [];
+
+ int exitCode = await RunCommand.ExecuteAsync(
+ fileName,
+ arguments,
+ new LineOutputHandler(onStandardOutput: output.Add),
+ new CommandOptions { WorkingDirectory = AbsoluteDirectoryPath.Create(directory) }).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode, "Expected the command to run successfully.");
+
+ // Comparing only the final segment keeps this robust where the temporary directory is
+ // reached through a symlink, as it is on macOS, and the process reports the resolved path
+ // rather than the one it was handed.
+ Assert.AreEqual(
+ Path.GetFileName(directory),
+ Path.GetFileName(string.Concat(output).Trim()),
+ "Expected the process to start in the directory it was given.");
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldInheritTheCurrentDirectoryWhenNoWorkingDirectoryIsGiven()
+ {
+ (string fileName, string[] arguments) = GetPrintWorkingDirectoryCommand();
+ List output = [];
+
+ int exitCode = await RunCommand.ExecuteAsync(
+ fileName,
+ arguments,
+ new LineOutputHandler(onStandardOutput: output.Add),
+ new CommandOptions()).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode, "Expected the command to run successfully.");
+ Assert.AreEqual(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(Environment.CurrentDirectory)),
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(string.Concat(output).Trim())),
+ ignoreCase: RuntimeInformation.IsOSPlatform(OSPlatform.Windows),
+ "Expected an unset working directory to leave the previous behaviour untouched.");
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldThrowArgumentNullExceptionWhenOptionsAreNull()
+ {
+ await Assert.ThrowsAsync(
+ () => RunCommand.ExecuteAsync("dotnet", ["--version"], new OutputHandler(), null!)).ConfigureAwait(false);
+ }
}
diff --git a/RunCommand/CommandOptions.cs b/RunCommand/CommandOptions.cs
new file mode 100644
index 0000000..7d13390
--- /dev/null
+++ b/RunCommand/CommandOptions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.RunCommand;
+
+using ktsu.Semantics.Paths;
+
+///
+/// Describes how to shape the process a command runs in, beyond the executable and its arguments.
+///
+///
+/// Every member defaults to the behaviour commands had before this type existed, so an instance
+/// with nothing set is equivalent to not passing one at all.
+///
+public sealed record CommandOptions
+{
+ ///
+ /// Gets the directory the process starts in, or to inherit the current
+ /// directory of the calling process.
+ ///
+ ///
+ /// The type is deliberately absolute. A relative directory would have to be resolved against the
+ /// calling process's current directory, which is the process-global state this property exists
+ /// to stop callers depending on in the first place.
+ ///
+ public AbsoluteDirectoryPath? WorkingDirectory { get; init; }
+
+ ///
+ /// Gets the privilege level under which to run the command.
+ ///
+ public Elevation Elevation { get; init; } = Elevation.Default;
+}
diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs
index 1947383..6b4add5 100644
--- a/RunCommand/RunCommand.cs
+++ b/RunCommand/RunCommand.cs
@@ -78,6 +78,22 @@ public static int Execute(string fileName, IEnumerable arguments) =>
public static int Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler) =>
ExecuteAsync(fileName, arguments, outputHandler).Result;
+ ///
+ /// Executes a shell command synchronously with an output handler and the given process options,
+ /// passing arguments individually so that no manual quoting is required.
+ ///
+ /// The executable to run.
+ /// The arguments to pass, each as a separate unquoted value.
+ ///
+ /// The handler for processing command output. Not invoked when
+ /// is on Windows because elevation requires UseShellExecute,
+ /// which is incompatible with output redirection.
+ ///
+ /// The options shaping the process the command runs in.
+ /// The exit code of the executed process.
+ public static int Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options) =>
+ ExecuteAsync(fileName, arguments, outputHandler, options).Result;
+
///
/// Executes a shell command asynchronously
///
@@ -168,7 +184,7 @@ public static async Task ExecuteAsync(string command, OutputHandler outputH
string filename = commandParts[0];
string arguments = commandParts.Length > 1 ? commandParts[1] : string.Empty;
- ProcessStartInfo startInfo = CreateStartInfo(filename, outputHandler, elevation, out bool useElevation);
+ ProcessStartInfo startInfo = CreateStartInfo(filename, outputHandler, new CommandOptions { Elevation = elevation }, out bool useElevation);
startInfo.Arguments = arguments;
return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false);
@@ -228,21 +244,59 @@ public static async Task ExecuteAsync(string fileName, IEnumerable
/// A task representing the asynchronous operation with the process exit code.
/// The token was cancelled before the process exited.
public static async Task ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)
+ => await ExecuteAsync(fileName, arguments, outputHandler, new CommandOptions { Elevation = elevation }, cancellationToken).ConfigureAwait(false);
+
+ ///
+ /// Executes a command asynchronously with an output handler and the given process options,
+ /// passing arguments individually so that no manual quoting is required.
+ ///
+ /// The executable to run.
+ /// The arguments to pass, each as a separate unquoted value.
+ ///
+ /// The handler for processing command output. Not invoked when
+ /// is on Windows because elevation requires UseShellExecute,
+ /// which is incompatible with output redirection.
+ ///
+ /// The options shaping the process the command runs in.
+ /// A task representing the asynchronous operation with the process exit code.
+ public static async Task ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)
+ => await ExecuteAsync(fileName, arguments, outputHandler, options, CancellationToken.None).ConfigureAwait(false);
+
+ ///
+ /// Executes a command asynchronously with an output handler and the given process options,
+ /// passing arguments individually so that no manual quoting is required, and cancelling the
+ /// process if the token is signalled.
+ ///
+ /// The executable to run.
+ /// The arguments to pass, each as a separate unquoted value.
+ ///
+ /// The handler for processing command output. Not invoked when
+ /// is on Windows because elevation requires UseShellExecute,
+ /// which is incompatible with output redirection.
+ ///
+ /// The options shaping the process the command runs in.
+ ///
+ /// A token that, when cancelled, terminates the running process and its children.
+ ///
+ /// A task representing the asynchronous operation with the process exit code.
+ /// The token was cancelled before the process exited.
+ public static async Task ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)
{
Ensure.NotNull(fileName);
Ensure.NotNull(arguments);
Ensure.NotNull(outputHandler);
+ Ensure.NotNull(options);
- ProcessStartInfo startInfo = CreateStartInfo(fileName, outputHandler, elevation, out bool useElevation);
+ ProcessStartInfo startInfo = CreateStartInfo(fileName, outputHandler, options, out bool useElevation);
SetArguments(startInfo, arguments);
return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false);
}
- private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, Elevation elevation, out bool useElevation)
+ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation)
{
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
- useElevation = elevation == Elevation.Elevated && isWindows;
+ useElevation = options.Elevation == Elevation.Elevated && isWindows;
ProcessStartInfo startInfo = new()
{
@@ -250,6 +304,11 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o
CreateNoWindow = true,
};
+ if (options.WorkingDirectory is not null)
+ {
+ startInfo.WorkingDirectory = options.WorkingDirectory.WeakString;
+ }
+
if (useElevation)
{
startInfo.UseShellExecute = true;
diff --git a/RunCommand/RunCommand.csproj b/RunCommand/RunCommand.csproj
index cf8181d..31c34ed 100644
--- a/RunCommand/RunCommand.csproj
+++ b/RunCommand/RunCommand.csproj
@@ -8,6 +8,8 @@
+
+