ProcessKit works well as the process layer in an .fsx script: you keep the quick edit-run loop of
shell scripting, but arguments, outcomes, cancellation, and pipelines remain typed. Commands are
launched directly rather than through a shell, and every ordinary run stays inside ProcessKit's
kill-on-dispose containment.
- Load the package
- Cross the asynchronous boundary once
- Use
Execfor one-off tools - Use
CliClientfor a tool you call repeatedly - Build pipelines without shell syntax
- Handle Ctrl+C without orphaning the run
- Return an honest exit code
- Resolve tools portably
- Cross-platform checklist
Put the NuGet reference at the top of the script:
#r "nuget: ProcessKit"
For repeatable automation, pin the exact package version you have tested:
#r "nuget: ProcessKit, <version>". F# Interactive restores the package on the first run and reuses
the NuGet cache afterwards. Then import the namespaces your script needs:
open System
open System.Threading
open ProcessKitRun the file with dotnet fsi build.fsx. Use -- before arguments intended for your script:
dotnet fsi build.fsx -- --configuration Release.
The #r line is shown as text because it is an FSI directive, not valid in a compiled .fs file.
Every executable fsharp block on this page is still extracted and compiled against the current
ProcessKit assemblies by scripts/verify-doc-snippets.ps1.
ProcessKit verbs return Task<Result<_, ProcessError>>. Keep composition asynchronous in functions,
then block once at the script's top level; repeated .Result calls inside the workflow make error
handling harder and can serialize work that should overlap.
let inspectRepository () =
task {
match! Exec.run "git" [ "rev-parse"; "--show-toplevel" ] with
| Ok root ->
printfn $"repository: {root}"
return Ok()
| Error error -> return Error error
}
let inspection =
inspectRepository().GetAwaiter().GetResult()
match inspection with
| Ok() -> ()
| Error error -> eprintfn $"inspection failed: {error.Message}"GetAwaiter().GetResult() preserves the original exception if the script itself has a bug. Normal
process failures are not exceptions here: they remain the Error ProcessError value you match.
Exec is the shortest route for a command used once. Exec.run requires an accepted exit and returns
trimmed stdout; Exec.outputString returns the full ProcessResult<string>, where a non-zero exit is
data rather than an Error.
let version =
Exec.run "dotnet" [ "--version" ]
|> fun pending -> pending.GetAwaiter().GetResult()
match version with
| Ok value -> printfn $"SDK {value}"
| Error error -> eprintfn $"dotnet failed: {error.Message}"
let status =
Exec.outputString "git" [ "status"; "--short" ]
|> fun pending -> pending.GetAwaiter().GetResult()
match status with
| Ok result ->
printf "%s" result.Stdout
eprintf "%s" result.Stderr
| Error error -> eprintfn $"git could not be run: {error.Message}"Arguments are separate strings. Do not pre-quote them or concatenate a command line: ProcessKit passes the argument list directly to the child.
A CliClient keeps one program name and shared Command defaults. It is useful for scripts that call
Git, dotnet, ffmpeg, or another CLI many times.
let git =
(CliClient.create "git")
.WithDefaults(fun command ->
command
.CurrentDir(Environment.CurrentDirectory)
.Timeout(TimeSpan.FromSeconds 30.0))
let head =
git.RunAsync [ "rev-parse"; "HEAD" ]
|> fun pending -> pending.GetAwaiter().GetResult()
let recent =
git.OutputStringAsync [ "log"; "--oneline"; "-n"; "5" ]
|> fun pending -> pending.GetAwaiter().GetResult()
match head, recent with
| Ok sha, Ok log -> printfn $"HEAD {sha}\n{log.Stdout}"
| Error error, _
| _, Error error -> eprintfn $"git failed: {error.Message}"Use client.Command args when one invocation needs an extra builder option; the returned immutable
Command keeps all client defaults.
Command.Pipe connects stdout to stdin without invoking bash, cmd.exe, or PowerShell. There is no
shell quoting, word splitting, wildcard expansion, or injection surface, and ProcessKit contains the
whole chain in one process group.
let authors =
(Command.create "git" |> Command.args [ "log"; "--format=%an" ])
.Pipe(Command.create "sort")
match authors.OutputStringAsync().GetAwaiter().GetResult() with
| Ok result -> printf "%s" result.Stdout
| Error error -> eprintfn $"pipeline failed: {error.Message}"Shell operators are not arguments: >, 2>&1, |, &&, $VAR, and *.fs have no special meaning.
Use the corresponding ProcessKit builder (Stdout, MergeStderr, Pipe, environment builders) or
expand files in F# before creating the command.
For a live handle, ForwardParentSignals installs and owns the signal handlers, suppresses the
parent's immediate termination, and forwards the first request into the run's graceful
StopSignal → grace → hard-kill path. It automatically unregisters when the child exits; disposing
the returned scope unregisters earlier.
let runWithCtrlC () =
task {
match! (Command.create "long-running-tool").StartAsync() with
| Error error -> return Error error
| Ok running ->
use running = running
use _signals = running.ForwardParentSignals(TimeSpan.FromSeconds 5.0)
return! running.OutputStringAsync()
}
let childExit = runWithCtrlC().GetAwaiter().GetResult()var started = await new Command("long-running-tool").StartAsync();
if (started is { IsOk: true, ResultValue: var running })
{
await using (running)
using (running.ForwardParentSignals(TimeSpan.FromSeconds(5)))
await running.OutputStringAsync();
}On POSIX the scope handles SIGINT and SIGTERM. On Windows it handles Ctrl+C and Ctrl+Break,
but forwarding means the existing Windows StopAsync contract — best-effort WM_CLOSE, then atomic
Job termination after the grace window — not a guarantee that a windowless console child receives
the original Ctrl event. Repeated signals while the scope is active do not start duplicate teardown.
Do not call Environment.Exit: it bypasses the unwind the forwarding scope protects. A crash,
SIGKILL, or TerminateProcess cannot run
managed disposal; if sudden parent death is in scope, read
KillOnParentDeath
before opting in. Its honest scope is platform-specific: whole tree on Windows, direct child only on
Linux, and unavailable on macOS/BSD.
Completion verbs can instead receive a cancellation token for the whole run and return
ProcessError.Cancelled; ForwardParentSignals is specifically for a caller-owned live handle where
you want an honest graceful Outcome.
ExitCodeAsync returns the child's real code. A timeout or signal is an error instead of a made-up
sentinel; cancellation can be mapped to the conventional 130. Set Environment.ExitCode only after
ProcessKit has finished cleanup.
let publishExitCode (childExit: Result<int, ProcessError>) =
let scriptExitCode =
match childExit with
| Ok code -> code
| Error(ProcessError.Cancelled _) -> 130
| Error error ->
eprintfn $"run failed: {error.Message}"
1
Environment.ExitCode <- scriptExitCodeCall publishExitCode childExit with the result from the previous section.
When you also need stdout and stderr, use OutputStringAsync: its Ok result carries Code,
Outcome, Stdout, and Stderr, including for a non-zero exit. Only failure to start or drive the
child is Error on that capture path.
Choose the preflight that answers the question you actually have:
Exec.which "git"andCliClient.EnsureAvailableAsync()inspect the host process'sPATH.Command.ResolveProgram()resolves the command's effective childPATH, includingEnv,EnvClear, andPreferLocal.CliClient.ResolveProgram()does the same for the client's configured template.
let formatter =
(CliClient.create "eslint")
.WithDefaults(fun command -> command.PreferLocal("node_modules/.bin"))
match formatter.EnsureAvailableAsync().GetAwaiter().GetResult() with
| Ok path -> printfn $"installed on host: {path}"
| Error _ -> printfn "not on the host PATH"
match formatter.ResolveProgram() with
| Ok path -> printfn $"this client will launch: {path}"
| Error error -> eprintfn $"client cannot resolve its tool: {error.Message}"The two answers may legitimately differ when a script supplies its own PATH or prefer-local
directory. Resolution is side-effect-free: it never launches the tool.
- Keep arguments separate and use
Pipelineinstead of shell strings. A shell built-in is not an executable; if you genuinely need shell syntax, invoke that shell explicitly and accept its quoting contract. - Use
System.IO.Pathrather than embedding/or\in paths you construct. - Windows executable lookup is
PATHEXT-aware, so bare tool names resolve consistently throughExec.which,ResolveProgram, and the real spawn path. - UTF-8 remains the default. For a legacy Windows console program that writes an OEM/console code page,
add
.ConsoleEncoding()to theCommand; it is an unchanged UTF-8 choice off Windows. PreferLocalpaths are resolved against the command's working directory when one is set, otherwise against the script process's current directory.- Treat
Resultexplicitly and setEnvironment.ExitCode; an unhandledProcessErrorprinted as text is not the same thing as a failing script.
Next: Running commands