The subprocess seam
Using this package’s subprocess helper for your own commands, and injecting a fake so unit tests never spawn anything.
The only journey here that involves no model at all. Full signatures are in the exec reference.
Why it is worth using
Section titled “Why it is worth using”realExec exists because the claude-cli provider needs a subprocess helper, and the one it needed
turned out to be non-trivial. It is exported, tested, cross-platform, and already a dependency if
you use this package at all.
Four behaviors were each earned by a real failure:
| Behavior | The failure it prevents |
|---|---|
| argv array, never a shell | quoting hazards and injection; npm shims still resolve on Windows |
input piped to stdin |
user content exceeding the ~32K Windows command-line limit |
env values accept undefined to unset |
empty string and absent are different things to git |
timeout settles on the timer, not on close |
a child that ignores SIGTERM hanging the caller |
Both pipes also use setEncoding("utf8"), so a multi-byte character split across chunk boundaries
decodes correctly instead of becoming replacement characters.
// The subprocess seam, used for a command that has nothing to do with inference.// Runs with no API key and no network — it spawns this same Node binary.
import { makeProvider, realExec } from "@hawkeyexl/inference";
// An argv array, never a shell string. No quoting hazards, no injection surface.const version = await realExec([process.execPath, "--version"]);console.log("exit code:", version.code);console.log("looks like a version:", /^v\d+\./.test(version.stdout.trim()));
// It returns rather than throws. A command that cannot start reports spawnError.const missing = await realExec(["definitely-not-a-real-command-xyz"]);console.log("spawn failed without throwing:", missing.spawnError !== undefined);
// A nonzero exit is data, not an exception.const failed = await realExec([process.execPath, "-e", "process.exit(3)"]);console.log("nonzero exit reported:", failed.code);
// Input is piped to stdin and the stream closed. This is how the claude-cli provider// sends prompts: user content routinely exceeds the ~32K Windows argv limit.const big = "x".repeat(40_000);const echoed = await realExec( [process.execPath, "-e", "process.stdin.on('data', d => process.stdout.write(String(d.length)))"], { input: big },);console.log("bytes received over stdin:", echoed.stdout.trim());
// An env value of undefined UNSETS an inherited variable rather than blanking it.// Empty string and absent are different things to many programs.process.env["EXAMPLE_INHERITED"] = "from-parent";const unset = await realExec( [process.execPath, "-e", "console.log('EXAMPLE_INHERITED=' + (process.env.EXAMPLE_INHERITED ?? '<unset>'))"], { env: { EXAMPLE_INHERITED: undefined } },);console.log(unset.stdout.trim());
// A timeout settles on the timer itself, so a child that ignores SIGTERM// cannot hang the caller.const slow = await realExec([process.execPath, "-e", "setTimeout(() => {}, 60000)"], { timeoutMs: 300,});console.log("timed out cleanly:", slow.timedOut);
// The same type is the test seam. Inject a fake and never spawn in a unit test.const calls = [];const fakeExec = async (cmd, opts) => { calls.push({ cmd, input: opts?.input }); return { code: 0, stdout: JSON.stringify({ result: "{}" }), stderr: "", timedOut: false };};makeProvider({ provider: "claude-cli", exec: fakeExec });console.log("ExecFn accepted as a provider seam:", typeof fakeExec === "function");exit code: 0looks like a version: truespawn failed without throwing: truenonzero exit reported: 3bytes received over stdin: 40000EXAMPLE_INHERITED=<unset>timed out cleanly: trueExecFn accepted as a provider seam: trueIt returns, it does not throw
Section titled “It returns, it does not throw”interface ExecResult { code: number | null; stdout: string; stderr: string; timedOut: boolean; spawnError?: string; // present when the process could not start}Three failure modes, three fields — none of them exceptions:
const result = await realExec(["git", "log", "-1", "--format=%H"], { cwd: repoRoot });
if (result.spawnError !== undefined) return warn(`git not found: ${result.spawnError}`);if (result.timedOut) return warn("git timed out");if (result.code !== 0) return warn(`git failed: ${result.stderr.trim()}`);
use(result.stdout.trim());Unsetting an inherited variable
Section titled “Unsetting an inherited variable”await realExec(["git", "log"], { env: { GIT_DIR: undefined, GIT_WORK_TREE: undefined } });undefined removes the variable from the child’s environment. Setting it to "" would leave it
present but empty, which git treats as a real (and broken) value. This distinction is the one
upstream type fix the library’s extraction required, and it came from exactly this use case.
Keys you do not mention are inherited unchanged.
Two default timeouts
Section titled “Two default timeouts”| Where | Default |
|---|---|
realExec (opts.timeoutMs) |
60000 ms |
ClaudeCliProvider constructor |
180000 ms |
The provider passes its own value through, so the 60-second default applies only when you call
realExec yourself.
Inject a fake in tests
Section titled “Inject a fake in tests”ExecFn is the seam — the same type realExec satisfies:
type ExecFn = (cmd: string[], opts?: ExecOptions) => Promise<ExecResult>;Pass one through ProviderSpec.exec for claude-cli, or accept one in your own function and
default it to realExec:
export async function currentCommit(repoRoot: string, exec: ExecFn = realExec) { const result = await exec(["git", "log", "-1", "--format=%H"], { cwd: repoRoot }); return result.code === 0 ? result.stdout.trim() : undefined;}Your unit test passes a fake and asserts on the argv without spawning anything. See testing your integration.
- Exec reference — full signatures and defaults
- Testing your integration — all three seams together