Running commands

‹ docs index

Command is the entry point of the runner layer: a builder describing what to run and how, plus a family of consuming verbs that decide what you get back. Every one-shot verb spawns the child into a fresh, private kill-on-drop process group, so an early return, panic, or dropped future can never leak a process tree.

Program, arguments, working directory

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("git")
        .arg("log")                          // one at a time…
        .args(["--oneline", "-n", "10"])     // …or in bulk
        .current_dir("/path/to/repo")        // run there
        .run()
        .await?;
    Ok(())
}

Arguments are passed as an array — there is no shell between you and the child, so there is no quoting, no word-splitting, and no injection surface. (When you actually want a | b | c, use a pipeline, which connects the stages in-process instead of invoking a shell.)

The program name reaches the OS verbatim — two deliberate non-goals (conveniences some libraries layer on, e.g. duct): a bare name is resolved on PATH by the OS, never rewritten to ./name; and current_dir does not re-anchor a relative program path against the new directory — whether Command::new("./tool").current_dir(dir) resolves tool relative to dir is the platform's behavior (Unix: yes; Windows: the parent's directory may win). Pass absolute program paths when combining the two.

For quick one-liners the free functions skip the builder:

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let version = processkit::run("cargo", ["--version"]).await?;       // trimmed stdout, success required
    let result  = processkit::output_string("git", ["status", "-s"]).await?;   // full ProcessResult
    Ok(())
}

Resolving a locally-installed tool: prefer_local

prefer_local adds a directory to check before the system PATH when resolving a bare-name program for this one run — for a project's own node_modules/.bin, a target/debug build, or a vendored toolchain, without hand-rolling a PATH override:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("eslint")
        .prefer_local("./node_modules/.bin")
        .arg("src/")
        .output_string()
        .await?;
    Ok(())
}

Resolution order. Repeated calls accumulate, in priority order: the directory from the first call is probed first, then the second, and so on, with the system PATH tried last as the final fallback:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    Command::new("tool")
        .prefer_local("./vendor/bin")   // checked first
        .prefer_local("./target/debug") // checked second
        .run()                          // then the system PATH
        .await?;
    Ok(())
}

Resolution reuses the exact same PATHEXT-aware lookup as the PATH search (the same internal probe_dir helper — not a separate implementation), so a .exe/.cmd/.bat on Windows is found under a prefer_local directory exactly as it would be on PATH.

Only a bare name is affected. If the program passed to Command::new is a path — absolute, or relative with a separator ("./tool", "../bin/x") — prefer_local has no effect at all: the existing contract that such a program is never looked up on PATH (or here) is unchanged.

Interaction with PATH/inherit_env/env. prefer_local only changes where the parent looks to resolve the program for this one launch. It does not rewrite or extend the PATH the child sees in its own environment — that is governed entirely by env/inherit_env/env_clear, as usual. When the program is found under a prefer_local directory, the child is simply spawned via that resolved absolute path instead of the bare name; a grandchild the program itself spawns does not inherit this reach — only the one program named in this Command benefits.

Interaction with current_dir. A relative prefer_local directory (as in the examples above) is probed against the process's actual current directory, never against whatever is set via current_dir on the same Command. The resolved match is then always turned into an absolute path before being handed to the OS, so it can't later be reinterpreted against the child's working directory once current_dir is set — unlike a relative-path program passed straight to Command::new, which is subject to that footgun (see Program, arguments, working directory above).

Diagnostics. If resolution fails everywhere, ErrorReason::NotFound's searched field includes the prefer_local directories too — first, in priority order, ahead of the PATH directories — so the diagnostic never hides that they were checked.

Preflight: resolve a program without running it

Sometimes you want to know whether an external tool is available before you run it — a doctor check at startup, a friendly "is git installed?" error up front — with no side effects. resolve_program locates a command's program and returns its absolute path without spawning anything:

use processkit::Command;

fn main() -> processkit::Result<()> {
    // `which` is the crate-level shortcut for a bare tool.
    let git = processkit::which("git")?;   // Ok(/usr/bin/git) or Err(NotFound)
    println!("git lives at {}", git.display());

    // On a builder it honors that command's own `prefer_local` and env, so it
    // resolves exactly what a real run of that command would launch.
    let eslint = Command::new("eslint")
        .prefer_local("./node_modules/.bin")
        .resolve_program()?;
    println!("eslint lives at {}", eslint.display());
    Ok(())
}

No divergence from a real run. Resolution reuses the crate's own launch-path logic — the same PATH/PATHEXT/execute-bit resolution and prefer_local handling a spawn performs, not a second copy — so a resolve_program hit is exactly the executable a run would launch, and a miss is exactly the ErrorReason::NotFound (with the same searched diagnostic and is_not_found() classification) a run would raise. A command that relocates the child's PATH (env/env_remove of PATH, env_clear, inherit_env) is resolved against that effective child PATH, so preflight still matches the spawn.

It is a synchronous, cheap filesystem probe (a few stats) — no async runtime is required, and no process is ever started. Contrast probe(), which runs the tool to read its exit code; resolve_program only locates it.

fn main() {
    match processkit::which("definitely-not-installed") {
        Ok(path) => println!("found: {}", path.display()),
        Err(e) if e.is_not_found() => eprintln!("tool not installed"),
        Err(e) => eprintln!("resolution error: {e}"),
    }
}

For a tool wrapped behind a CliClient, CliClient::resolve_program() does the same for the client's program, honoring its env defaults. The dedicated typed CLI clients guide covers wrapper structure, shared defaults, parsing, and hermetic tests end to end.

Environment

Four builders compose, applied in a fixed order at spawn:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    Command::new("worker")
        .env("RUST_LOG", "debug")        // set one variable
        .env_remove("GIT_DIR")           // unset one inherited variable
        .run().await?;

    // Unix: choose a multicall mode without changing executable resolution.
    Command::new("busybox").arg0("httpd").arg("-f").run().await?;

    // Allow-list mode: clear everything, copy only the named parent variables.
    Command::new("sandboxed-tool")
        .inherit_env(["PATH", "HOME", "LANG"])
        .env("MODE", "ci")               // explicit env/env_remove still apply on top
        .run().await?;

    // Scorched earth: the child starts with an empty environment.
    Command::new("hermetic-tool").env_clear().run().await?;
    Ok(())
}

inherit_env is the sandboxing middle ground: it implies env_clear, then copies the listed variables from the parent at each spawn (so a retry sees fresh values), and repeated calls accumulate names. A name the parent doesn't have is skipped, not set to empty.

Standard input

By default stdin is closed at spawn — the child reads EOF immediately and can never hang waiting for input. Everything else is opt-in via stdin(Stdin::…):

SourceReusable on re-run?Use for
Stdin::empty()The default, explicit
Stdin::from_string("…")Text payloads
Stdin::from_bytes(vec![…])Binary payloads
Stdin::from_iter_lines(["a", "b"])Anything iterable; each item is written \n-terminated
Stdin::from_file(path)✅ (re-opened per run)Large inputs streamed from disk
Stdin::from_reader(reader)❌ one-shotAny AsyncRead — a socket, a decompressor, …
Stdin::from_lines(stream)❌ one-shotAny Stream<Item = String> — a channel, a tail, …
use processkit::{Command, Stdin};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let sorted = Command::new("sort")
        .stdin(Stdin::from_iter_lines(["banana", "apple", "cherry"]))
        .run()
        .await?;
    assert_eq!(sorted, "apple\nbanana\ncherry");
    Ok(())
}

The payload is written on a background task (so a large input can't deadlock against the child's output) and the pipe is dropped afterwards to signal EOF. The two one-shot sources are consumed by their first run: a retried or cloned command reusing them fails loud the second time — re-running a consumed from_reader/from_lines source is an ErrorReason::Io (InvalidInput) at launch (D10), not a silent empty stdin. Prefer the reusable sources when a command may run more than once.

For conversational, request/response stdin — write a line, read the answer, repeat — use keep_stdin_open() and the streaming API instead: see Streaming & interactive I/O.

Inheriting the parent's stdin: inherit_stdin()

inherit_stdin() hands the child the parent's own standard input — it reads directly from whatever this process's stdin is (a terminal, a file, a pipe) rather than from a crate-managed pipe. It is the stdin counterpart of stdout(StdioMode::Inherit) / stderr(StdioMode::Inherit): the child shares the parent stream instead of the crate mediating it.

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // `git commit` opens $EDITOR on the parent's terminal; the child talks to the
    // real tty directly. stdout/stderr are still captured as usual.
    Command::new("git").arg("commit").inherit_stdin().run().await?;
    Ok(())
}

Reach for it when a child must talk to the real terminal — git commit opening $EDITOR, a tool prompting for a password or a yes/no — or to forward the parent's piped stdin straight through. This covers the common non-tty-negotiating interactive cases without the crate having to pump bytes; a tool that truly demands a tty (not just inherited stdin) instead wants use_pty (the pty feature). Because the child reads the parent's stdin directly, the crate neither feeds nor captures that input, and take_stdin() returns None (as for a non-keep_stdin_open run). Capturing and streaming the child's output is unaffected.

Why a dedicated verb rather than a Stdin::Inherit source or a mode enum. For stdout/stderr the three StdioMode variants map cleanly onto one setter, but stdin's "piped" case is not modeless — it needs a payload (which source? what bytes?), already expressed by stdin(Stdin::…), and its "null" case is Stdin::empty(). Folding inheritance into that same stdin(Stdin) field would make "inherit and a source" collapse to silent last-write-wins, impossible to flag. A separate inherit_stdin() keeps the two intents in distinct fields so an incompatible pairing is a detectable, rejectable error instead.

Accordingly, inherit_stdin() is mutually exclusive with either way the crate would otherwise drive stdin — a configured stdin(Stdin::…) source (including an explicit Stdin::empty()) or keep_stdin_open()'s interactive pipe. Setting inherit_stdin() together with one of those is a contradiction (feed the child a source and let it read the terminal?), so it is refused at the launch boundary with a typed ErrorReason::Io (InvalidInput) — the same failure mode as re-running a consumed one-shot source — rather than silently letting one win. Drop the other stdin knob to resolve it. The refusal is enforced on the same launch seam the hermetic test doubles route through, so a ScriptedRunner rejects the conflict exactly as a live run does.

Redirecting output directly to a file

stdout_file(path) and stderr_file(path) give the child a file descriptor at spawn (Stdio::from(File)). They do not tee through a parent task: no output is line-pumped, decoded, or retained in memory, and the child can keep writing if the parent exits suddenly.

The plain builder creates the file when needed and truncates it for that spawn. Use the explicit *_file_truncate spelling when choosing a mode in a conditional expression, or *_file_append to preserve existing contents and append each new child incarnation:

use processkit::{Command, Supervisor};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let service = Command::new("my-service")
        .stdout_file_append("service.log") // every Supervisor incarnation shares this log
        .stderr_file_append("service.log");

    Supervisor::new(service).run().await?;
    Ok(())
}

This is the low-overhead choice for a service under Supervisor that writes its own log: append mode accumulates every restart in one file, while truncate mode starts a fresh log for each spawn. The command's stdout(StdioMode::…) / stderr(StdioMode::…) setters are last-wins and clear a prior file destination.

A redirected stdout is deliberately not piped. output_string, output_bytes, stdout_lines, and events therefore reject it just as they reject Inherit/Null; call start().await?.wait().await? (or supervise the command) when only its exit outcome matters. Stderr may be redirected independently; it does not prevent stdout capture.

Output handling

Encodings

Output is decoded line by line, UTF-8 by default (invalid bytes become U+FFFD, never an error). Legacy-encoding tools can override per stream:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("legacy-tool")
        .encoding(encoding_rs::SHIFT_JIS)          // both streams…
        // .stdout_encoding(…) / .stderr_encoding(…) // …or each its own
        .output_string()
        .await?;
    Ok(())
}

(processkit::prelude::Encoding re-exports encoding_rs::Encoding, so any of its encodings works — the single-byte and ASCII-compatible multibyte ones (WINDOWS_1252, GBK, SHIFT_JIS, …) and the non-ASCII-compatible ones (UTF_16LE/UTF_16BE): output is fed through one persistent decoder and split on decoded newlines, so a 0x0A byte inside a UTF-16 code unit is not mistaken for a line break. A leading byte-order mark of the chosen encoding is stripped once at the stream start.)

Buffer policies — bounding memory on chatty children

Captured lines are held in memory; a multi-gigabyte log would normally grow the buffer to match. output_buffer bounds retention (the pipe is always fully drained, so the child never blocks):

use processkit::{Command, OutputBufferPolicy, OverflowMode};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let tail = Command::new("verbose-build")
        .output_buffer(OutputBufferPolicy::bounded(1_000)) // keep the newest 1000 lines
        .output_string()
        .await?;

    // …or keep the head instead of the tail:
    let head_policy = OutputBufferPolicy::bounded(1_000).with_overflow(OverflowMode::DropNewest);
    Ok(())
}

DropOldest (the default) keeps a rolling tail; DropNewest freezes the head — a contiguous prefix of the output: the first line that doesn't fit seals the head, so every later line is dropped too (even a shorter one that would still fit), never leaving a set that skipped a dropped line and kept a later one. bounded(0) retains nothing — useful when a line handler (below) is the real consumer. Under a line cap, dropped or not, every line still feeds the handlers and the line counters.

The line cap alone does not bound memory — one enormous newline-free "line" (base64 -w0) is held whole. Add with_max_bytes to cap the retained bytes too (either ceiling, or both); the byte cap also bounds the pump's in-flight assembly buffer, so a never-terminated flood can't exhaust memory. One consequence: a line whose own length exceeds the byte cap can't be assembled, so it is dropped whole — counted, but not delivered to a per-line handler or stdout_tee (don't set a byte cap if a tee must see arbitrarily long lines):

#![allow(unused)]
fn main() {
use processkit::{Command, OutputBufferPolicy};
let policy = OutputBufferPolicy::unbounded().with_max_bytes(8 << 20); // 8 MiB ring
let strict = OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20); // error on either
}

fail_loud makes the ceiling error instead of dropping: the run fails with ErrorReason::OutputTooLarge once the cumulative output (lines or bytes) crosses the cap — even when a streaming consumer is draining lines as they arrive. It bounds memory, not wall-time, so pair it with timeout against a flooding child.

Even under a drop policy (DropOldest/DropNewest), the checking verbs that hand back stdout as if complete — run, parse, try_parse, output_jsonrefuse silently-truncated output (B12): if the policy dropped lines they fail with ErrorReason::OutputTooLarge rather than feed a parser a truncated tail. The lenient capture verbs (output_string / output_bytes) are unaffected — they return the partial result with truncated() set for you to inspect.

Line handlers — tee output as it arrives

on_stdout_line / on_stderr_line run a callback on each decoded line in addition to capture or streaming — logging, progress bars, metrics:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let result = Command::new("cargo")
        .args(["build", "--release"])
        .on_stderr_line(|line| eprintln!("[build] {line}"))
        .output_string()
        .await?;
    Ok(())
}

The handler runs on the read pump — keep it cheap. The contract is forgiving and precisely specified:

  • A panicking handler does not poison the run. The panic is caught, the handler is disabled for the rest of the run (surfaced as a tracing warn when that feature is on), and pumping continues — the final result still carries every line. You can safely re-export this callback seam to your own users without auditing their closures.
  • Ordering: invocations are FIFO within a stream; there is no ordering between stdout and stderr handlers (two independent pumps). On the consuming verbs, all handler calls happen-before the awaited future resolves — finalize a progress bar the moment the call returns. (One documented exception: a leaked pipe held open past the child's death is cut off after a bounded teardown grace.)
  • Handlers are hermetically testable: ScriptedRunner replays canned output through them — see Testing → scripting replies.

For a ready-made tee to an async sink — a file, socket, or any [tokio::io::AsyncWrite] — reach for stdout_tee / stderr_tee instead of hand-writing a handler. Each decoded line is written to the sink (plus a \n) as it is produced, awaited on the pump so a slow sink applies backpressure (the pump slows, the pipe fills, the child blocks) rather than blocking the runtime; a write error disables the tee with a tracing warn instead of being swallowed. It runs independently of on_stdout_line — set both and both fire per line.

Timeouts and retries

use processkit::{Command, ErrorReason};
use std::time::Duration;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("flaky-network-tool")
        .timeout(Duration::from_secs(30))                 // kill the tree at the deadline
        .retry(3, Duration::from_millis(200), |e| {       // up to 3 attempts total
            matches!(e.reason(), ErrorReason::Timeout { .. })            // …but only retry timeouts
        })
        .run()
        .await?;
    Ok(())
}
  • timeout attempts whole-tree teardown at the deadline. After terminal state is confirmed, capturing verbs keep the expiry as data (ProcessResult::timed_out) and success-checking verbs raise ErrorReason::Timeout; an unconfirmed kill/escalation/reap is ErrorReason::Teardown instead. The full decision table lives in Timeouts, retries & cancellation.
  • retry applies to the success-checking verbs only — run, run_unit, exit_code, probe, checked, parse, try_parse, and (with json) output_json (each runs through the retry loop). The classifier sees the typed error and decides. Cancelled and Teardown are terminal even when that classifier accepts them. The non-erroring output_string/output_bytes paths never retry, and neither does first_line (its stream search is single-attempt).

Privileges and spawn flags

Spawn-time controls for sandboxing and service launch:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // Unix: drop privileges (uid + gid + supplementary groups) and detach.
    Command::new("worker")
        .gid(1000)            // applied before uid (a gid change needs privilege)
        .groups([1000])       // replace the inherited (often root's) supplementary groups
        .uid(1000)            // dropped last
        .setsid()             // new session: survives the controlling terminal
        .run().await?;

    // Windows: no console window flashing up from a GUI app.
    Command::new("helper").create_no_window().run().await?;

    // Hardening: take the direct child down even if THIS process is SIGKILLed
    // (Drop never runs). Windows has this for free; Linux arms PDEATHSIG.
    Command::new("worker").kill_on_parent_death().start().await?;
    Ok(())
}

uid / gid / groups / setsid / arg0 are POSIX-only — on Windows the run fails with ErrorReason::Unsupported rather than silently skipping a privilege drop. A correct drop sets all three of uid/gid/groups: dropping the uid alone leaves the child holding the parent's (often root's) supplementary groups. arg0 changes only the value delivered as the child's first argument: lookup, preflight, containment, and spawn errors still use Command::program(). create_no_window is a harmless no-op outside Windows. kill_on_parent_death is best-effort by design: guaranteed on Windows (regardless of the knob), direct-child-only on Linux, unavailable on macOS/BSD — the graceful-exit guarantee via Drop holds everywhere either way. When the owner dies abruptly the kernel has no portable Unix way to take the whole tree down, so Command::kill_on_parent_death_scope() reports the honest reach as a ParentDeathCleanupWholeTree (Windows), DirectChildOnly (Linux), or Unsupported (macOS/BSD). A wrapper can surface that scope instead of overpromising a whole-tree cleanup:

#![allow(unused)]
fn main() {
use processkit::{Command, ParentDeathCleanup};

match Command::kill_on_parent_death_scope() {
    ParentDeathCleanup::WholeTree => { /* the whole tree dies with the owner */ }
    ParentDeathCleanup::DirectChildOnly => { /* only the direct child; grandchildren survive */ }
    ParentDeathCleanup::Unsupported => { /* no abrupt-death cleanup on this platform */ }
    _ => {}
}
}

Containment is preserved in every combination; the platform fine print (the Linux cgroup × uid interaction, setsid × process-group coordination, the pdeathsig thread caveat) is collected in Platform support.

Scheduling, umask, and per-process rlimits

Spawn-time knobs reuse the same seams as the builders above — Unix pre_exec and Windows' suspended-child configuration — for background/batch children that shouldn't starve the foreground, and for controlling the permissions of files a child creates:

use processkit::{Command, IoPriority, Priority, RlimitResource};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // Run at a lower CPU-scheduling priority — supported on BOTH platforms.
    Command::new("batch-job")
        .priority(Priority::BelowNormal)
        .run().await?;

    // Linux + Windows: keep a noisy worker on logical CPUs 2 and 3.
    Command::new("compiler")
        .cpu_affinity([2, 3])
        .run().await?;

    // Linux only: yield disk time to foreground users.
    Command::new("indexer")
        .io_priority(IoPriority::BestEffort(7))
        .run().await?;

    // Unix only: files this child creates get 0644/0755 instead of 0666/0777.
    Command::new("worker").umask(0o022).run().await?;

    // Unix only: disable core dumps and cap this child's open descriptors.
    Command::new("secret-worker")
        .rlimit(RlimitResource::Core, 0, 0)
        .rlimit(RlimitResource::NoFile, 256, 256)
        .run().await?;
    Ok(())
}

priority maps onto nice/setpriority on Unix and a priority class on Windows (Idle/BelowNormal/Normal/AboveNormal/High); unlike the privilege builders, every variant is supported on both platforms, so this knob never yields ErrorReason::Unsupported. One caveat: lowering nice below its inherited value on Unix — raising priority via Priority::AboveNormal/High, or even requesting Priority::Normal under a positively-niced parent (e.g. a niced CI/batch launcher) — needs CAP_SYS_NICE/root; without it the OS rejects the change and the spawn fails loud (ErrorReason::Spawn), never silently downgrading to a lower priority.

cpu_affinity accepts logical CPU indices. Linux applies a cpu_set_t with sched_setaffinity before exec; Windows calls SetProcessAffinityMask after race-free Job assignment while the child is still suspended (the ConPTY launch does the same), then resumes it. Descendants inherit the mask, though a child with sufficient rights may later change its own. Empty or unrepresentable sets fail before user code runs; an OS-rejected processor fails the spawn. macOS/BSD return ErrorReason::Unsupported. The Windows API is one processor-group mask, so indices are limited to the native mask width. spawn_detached refuses the knob; on Windows to_tokio_command does too because a raw command cannot carry the required post-spawn configuration seam.

io_priority is Linux-only: it calls ioprio_set(2) in pre_exec before the program starts. BestEffort(7) is the lowest normal Linux I/O priority; smaller data values are more urgent, while Idle runs only when the device is otherwise idle. RealTime can starve other users and normally needs CAP_SYS_ADMIN; a rejected request fails as ErrorReason::Spawn. On Windows, macOS/BSD, and other Unix targets, requesting I/O priority fails with ErrorReason::Unsupported rather than silently inheriting the caller's I/O priority. It is also refused by spawn_detached, whose owner-independent launch contract cannot honor it.

umask is Unix-only — like setsid/groups, requesting it on Windows fails with ErrorReason::Unsupported rather than being silently ignored.

Interactive auth / TTY. By default processkit wires pipes, not a pseudo-terminal, so a tool that demands a tty — an ssh/sudo password prompt, some credential helpers, an isatty()-gated agentic CLI — won't get one. Two ways to satisfy them:

  • Non-interactive (no PTY needed). Prefer this when possible: key-based auth, ssh -o BatchMode=yes, GIT_SSH_COMMAND / GIT_TERMINAL_PROMPT=0, or feed a known answer over interactive stdin. Conversational tools that read stdin without needing a tty work today via keep_stdin_open + stdout_lines.

  • PTY mode (use_pty, the pty feature). For a tool that truly requires a controlling terminal, Command::use_pty() launches it under a real pseudo-terminal — openpty on Unix, CreatePseudoConsole (ConPTY) on Windows — so isatty() reports a terminal. This is a minimal single-master-fd mode, not a terminal emulator, with four things to know:

    • stdout and stderr are merged onto the one master, so in this mode the on_stderr_line / stderr_tee split collapses and ProcessResult::stderr is empty — the whole output arrives through piped logical stdout. Both destinations must remain StdioMode::Piped: stdout or stderr Inherit/Null and *_file* redirects are rejected before a file is opened or a child is spawned, because the merged terminal cannot honor a separate per-descriptor destination.
    • Interactive input runs over the same master (keep_stdin_open + take_stdin); on Unix terminal echo is disabled so a written password is not echoed back into the merged output (the ConPTY has no portable per-write echo control, so that is Unix-only).
    • The child receives COLUMNS/LINES matching the initial PTY size (80×24, or pty_size(cols, rows)). A zero axis is rejected before spawn on every platform; Windows also rejects either axis above i16::MAX rather than clamping ConPTY's signed COORD, while Unix accepts the full remaining u16 range. Unix also defaults TERM=xterm-256color; Windows relies on ConPTY's console/VT APIs and does not synthesize TERM. Explicit env(...) or env_remove(...) calls for any of these names win.
    • Containment is unchanged — the PTY child lives in the same job/cgroup/process group, so whole-tree kill-on-drop, timeouts, and cancellation behave exactly as for a piped run.

    Scenario recipes live in the cookbook: run an isatty()-requiring tool, wait for an unterminated prompt and answer, consume agent-CLI progress, avoid full-duplex deadlock, and test the PTY contract hermetically. The streaming guide owns the detailed PTY I/O mechanics and output-hygiene knobs. For the end-to-end ssh case specifically — including the boundary where kill-on-drop stops at the local client — see Driving ssh.

    The historical defer/design is recorded in decisions/permissions-privileges-pty-network.md §4.

Consuming verbs

Typed JSON and NDJSON

With the additive json feature, output_json::<T>() runs to an accepted exit and deserializes the complete stdout. It is available on Command, ProcessRunnerExt, and CliClient, so a typed wrapper keeps the same verb when its runner changes from a real process to ScriptedRunner:

#![allow(unused)]
fn main() {
use processkit::Command;
use serde::Deserialize;

#[derive(Deserialize)]
struct Release {
    tag_name: String,
}

async fn example() -> processkit::Result<()> {
let release: Release = Command::new("gh")
    .args(["release", "view", "--json", "tagName"])
    .output_json()
    .await?;
println!("{}", release.tag_name);
Ok(())
}
}

For NDJSON, start the process and take a typed line stream. Each item is its own Result<T>: a malformed line is reported with its one-based line/column and zero-based byte offset, then the stream continues. Empty lines are errors rather than being silently skipped.

#![allow(unused)]
fn main() {
use processkit::prelude::StreamExt;
use processkit::Command;
use serde::Deserialize;

#[derive(Deserialize)]
struct Message {
    reason: String,
}

async fn example() -> processkit::Result<()> {
let mut process = Command::new("cargo")
    .args(["check", "--message-format=json"])
    .start()
    .await?;
let mut messages = process.stdout_json_lines::<Message>()?;
while let Some(message) = messages.next().await {
    println!("{}", message?.reason);
}
let finished = process.finish().await?;
assert_eq!(finished.outcome.code(), Some(0));
Ok(())
}
}

Both verbs reject incomplete data instead of pretending it is valid: output_json fails before parsing when a bounded capture was truncated, while the NDJSON stream inherits stdout_lines' fail-loud overflow and timeout contracts. JSON parse errors use ErrorReason::Parse; their child-controlled fragment is capped to 160 input bytes and control-escaped even in the public message field. NDJSON offsets refer to ProcessKit's decoded, \n-normalized stdout.

VerbReturnsNon-zero exitTimeoutUse when
output_string()ProcessResult<String>capturedcaptured (timed_out)You want to inspect the outcome yourself
output_bytes()ProcessResult<Vec<u8>>capturedcapturedBinary stdout (images, archives, …)
run()trimmed stdout StringErrorReason::ExitErrorReason::Timeout"Give me the answer or fail"
exit_code()i32the code, OkErrorReason::TimeoutThe code is the answer
probe()bool0true, 1false, else ErrorReason::ExitErrorReason::TimeoutPredicate commands: git diff --quiet, grep -q
output_json::<T>()TErrorReason::ExitErrorReason::TimeoutDeserialize one complete JSON document (json feature)
first_line(pred)Option<String>— (stream-based)ErrorReason::TimeoutGrab one matching line, kill the rest
start()live RunningProcessbounds the streamStreaming, interactive I/O, probes
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // probe(): the exit code as a boolean.
    let clean = Command::new("git").args(["diff", "--quiet"]).probe().await?;

    // first_line(): stop as soon as the interesting line appears.
    let first_match = Command::new("git")
        .args(["log", "--oneline"])
        .first_line(|l| l.contains("fix:"))
        .await?;
    Ok(())
}

first_line returns Ok(None) when stdout closes without a match, and kills the (private-group) child once it has its answer — you never wait out a long log for one line. A cancel_on token that fires while the search is still running surfaces as ErrorReason::Cancelled, so a readiness probe with a shutdown token can't misread token-driven teardown as "the line never appeared" — while a run that genuinely ends with no match still reports Ok(None), even if the token happens to fire an instant later.

Results and errors

The capturing verbs hand back a ProcessResult:

use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let result = Command::new("git").args(["merge", "feature"]).output_string().await?;

    result.code();         // Option<i32> — None = killed (timeout/signal), no code
    result.signal();       // Option<i32> — the signal number (Unix), else None
    result.is_success();   // code in ok_codes (default {0})
    result.timed_out();    // an absolute or inactivity timeout expired
    result.inactivity_timed_out(); // specifically: stdout/stderr went quiet
    result.outcome();      // the explicit disposition behind the accessors above
    result.stdout();       // &str (or &[u8] from output_bytes)
    result.stderr();       // &str
    result.combined();     // stdout + stderr concatenated
    result.diagnostic();   // stderr if non-empty, else stdout — the human-facing line
                           // (git/jj put "CONFLICT …" on stdout!)
    result.configured_timeout(); // Option<Duration> — the timeout this run was launched with
    result.ok_codes();     // &[i32] — the accepted exit codes ({0} by default)

    // Opt into erroring whenever you're ready:
    let ok = result.ensure_success()?; // Exit / Timeout / Signalled (signal-kill) as typed errors
    Ok(())
}

When the exact disposition matters, match on Outcome instead of mentally decoding the code()/timed_out() pair:

use processkit::Outcome;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let result = processkit::Command::new("git").args(["merge", "feature"]).output_string().await?;
    match result.outcome() {
        Outcome::Exited(0) => println!("clean"),
        Outcome::Exited(code) => println!("failed with {code}"),
        Outcome::Signalled(signal) => println!("killed by signal {signal:?}"),
        Outcome::TimedOut => println!("hit its deadline"),
        Outcome::InactivityTimedOut => println!("stopped producing output"),
        _ => {} // non_exhaustive: future dispositions
    }
    Ok(())
}

For a single query you usually don't need the match (and its #[non_exhaustive] wildcard): Outcome carries the same code() / signal() / timed_out() / inactivity_timed_out() accessors as ProcessResult, so a bare Outcome (from RunningProcess::wait or Finished::outcome) answers directly — outcome.code(), outcome.signal(), outcome.timed_out(). There is no Outcome::is_success (success is ok_codes-aware — use ProcessResult::is_success).

The error enum is structured and #[non_exhaustive]:

VariantMeaning
ErrorReason::Spawn { program, source }The program was located but the OS couldn't start it (permissions, a bad working directory, a Windows .cmd/.bat needing cmd.exe, …) — not is_not_found()
ErrorReason::NotFound { program, searched }The program couldn't be located (the single "not found" representation — is_not_found() is true); searched is Some(dirs) for a bare-name PATH lookup, None otherwise
ErrorReason::Exit { program, code, stdout, stderr, stdout_bytes }Non-zero exit, both streams attached in full (the Display message is bounded, but the fields carry the complete captured text for classification); stdout_bytes is Some(exact bytes) for a checking verb built over output_bytes, None on the text path — read via Error::stdout_bytes() (the variant is #[non_exhaustive])
ErrorReason::Signalled { program, signal, stdout, stderr, stdout_bytes }The process was killed by a signal (no exit code); signal carries the number on Unix, None elsewhere; the partial streams captured before the kill are attached (reach them via diagnostic()); stdout_bytes as above
ErrorReason::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }A fail_loud buffer's line or byte ceiling was exceeded
ErrorReason::Timeout { program, timeout, stdout, stderr, stdout_bytes }The run's own deadline killed it; whatever the run captured before the kill is attached — a hung tool's last stderr line tails the Display and is reachable via diagnostic(); stdout_bytes as above
ErrorReason::NotReady { program, timeout }A readiness probe gave up
ErrorReason::Parse { program, message }A try_parse parser (on Command, ProcessRunnerExt, CliClient, or Pipeline) or a typed JSON/NDJSON verb rejected the output. Generic callers own the full message; JSON helpers cap and control-escape their child-controlled detail/fragment before storing it, while Display/Debug additionally use a 200-byte preview.
ErrorReason::Stdin { program, source }Feeding the child's stdin failed for a non-broken-pipe reason on an otherwise-successful run (a louder failure — exit/signal/timeout — wins instead); a routine broken pipe never surfaces
ErrorReason::CassetteMiss { program }(record feature) a cassette replay found no matching recording (stale/incomplete cassette) — kept distinct from a missing program, so is_not_found() is false
ErrorReason::Unsupported { operation }The platform can't do what was asked (and silently skipping would be wrong)
ErrorReason::Cancelled { program }the run's token was cancelled
ErrorReason::ResourceLimit { kind, reason, detail }(limits feature) a requested cap couldn't be enforced — kind (LimitKind::Memory/Processes/Cpu) says which limit, reason (LimitReason::Invalid/Unsupported/Unenforceable) says why, without parsing detail's English text; read via Error::limit_kind()/limit_reason() (the variant is #[non_exhaustive])
ErrorReason::Io(source)A low-level IO error from the crate's own machinery (driving a child, group control, cassette files) — never an arbitrary foreign io::Error (no blanket From, D13)

Error::diagnostic() returns the most useful human-facing line out of a failure that captured output — Exit, and (D12) Timeout / Signalled (the partial streams of a hung-then-killed or crashed tool). Each of those variants' one-line Display also appends a bounded excerpt of that diagnostic (the last non-empty line, capped at 200 bytes), so a bare eprintln!("{e}") reads `git` exited with code 2: fatal: boom — actionable in a log line without dumping multi-KiB streams into it.

Escape hatch: a platform knob the crate doesn't model

Command exposes typed builders for the OS knobs that carry their weight — priority, cpu_affinity, io_priority, umask, create_no_window, windows_graceful_ctrl_break, run_as (uid/gid), parent_death, and so on. New real needs are added the same way: as a typed verb, so the command stays inspectable (its Debug, its Clone, and — with the record feature — the cassette it serialises to all stay truthful). There is deliberately no before_spawn-style hook that stores an opaque closure to mutate the raw command per launch: a stored mutator is invisible to Debug/Clone and would make a recorded cassette lie — it would replay the command as written while a different, mutated command actually ran. (The full reasoning is in decisions/before-spawn-hook-2026-07.md.)

When you genuinely hit a platform knob the crate has no verb for — a niche creation flag, your own pre_exec — the honest escape is to lower the builder to a raw tokio::process::Command and spawn it into a ProcessGroup:

use processkit::{Command, ProcessGroup};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build the OS command exactly as ProcessKit would: program resolution,
    // environment, working directory, scheduling/umask knobs, capture-wired stdio.
    let raw = Command::new("odd-tool")
        .args(["--serve"])
        .to_tokio_command()?;

    // `raw` is a plain `tokio::process::Command`. Set the platform knob the
    // builder has no typed verb for here — e.g. a raw creation flag on Windows
    // or your own `pre_exec` on Unix.

    // Spawn INTO a group so containment still holds (see below).
    let group = ProcessGroup::new()?;
    let mut child = group.spawn(raw)?;
    let status = child.wait().await?; // the bare tokio Child is yours to drive
    let _ = status;
    Ok(())
}

What survives, and what you give up. to_tokio_command() carries over everything the builder resolves at the OS level (program/args/cwd, the layered environment, the Unix priority/umask/privilege-drop/setsid pre_exec hooks (including Linux-only io_priority and cpu_affinity), Windows creation flags, and stdio wired for capture). Windows cpu_affinity is the deliberate exception: it needs a live, still-suspended process handle, so lowering such a command fails with Unsupported instead of dropping the request. Spawning the result through ProcessGroup::spawn still enrolls the child in the group's Job/cgroup/process-group, so containment is preserved — kill-on-drop and the group-level teardown verbs still reach it. What you leave behind is the high-level machinery that lives above the OS command: the async output pump and capture, the ProcessResult/RunningProcess verbs, and the per-run timeout/cancel_on/timeout_grace/windows_graceful_ctrl_break wiring. You own the bare tokio::process::Child — draining its pipes and reaping it are your job. (On Windows, spawn re-sets creation flags for a race-free job assignment, so a creation flag left on the raw command is overwritten there; prefer the typed create_no_window on a high-level launch path — see Process groups for the full raw-spawn contract.)


Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups