Skip to content

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.

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.

examples/exec-seam.mjs
// 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: 0
looks like a version: true
spawn failed without throwing: true
nonzero exit reported: 3
bytes received over stdin: 40000
EXAMPLE_INHERITED=<unset>
timed out cleanly: true
ExecFn accepted as a provider seam: true
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());
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.

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.

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.