bellpull

bellpull

Runs a subprocess, resolves the executable it names, and returns one structured result every caller can read: a line for a person, a --json envelope or an agent event. Zero dependencies. Drop-in paths for cross-spawn and which.

bellpull runs a subprocess, resolves the executable it names, and returns one structured result that every caller can read: format() for a person, toJson() for --json, toEvent() for an agent. A --json flag cannot report something the human output did not. Zero dependencies.

Drop-in for cross-spawn and npm's which: npx burgee migrate moves the imports to bellpull/cross-spawn and bellpull/node-which, each graded by the incumbent's own test suite. execa has no drop-in; run() below is the alternative.

Over the platform. Every run goes through node:child_process. What bellpull adds is what spawn does not do by name: resolve an executable the way the shell would (a .cmd shim on Windows, PATHEXT, a shebang), and hand back one result every caller can read. The repository's absorption probe asks every supported Node line whether child_process alone now starts the same commands.

A bellpull is the cord in one room wired to a bell in another. You pull it here; a bell rings there; someone comes back to you.

That is a subprocess. Request work at a distance, work happens elsewhere, a result returns. That last clause is the package.

Install

npm install bellpull
pnpm add bellpull
yarn add bellpull
bun add bellpull

Quick start

import { run, format } from 'bellpull';

const runtime = { platform: process.platform, env: process.env, cwd: process.cwd() };
const result = await run('git', ['rev-parse', 'HEAD'], { runtime });

result.ok; // false when it exited non-zero — no exception, no try/catch
result.code; // 128
result.executable; // { path: '/opt/homebrew/bin/git', from: '/opt/homebrew/bin' }

execa throws when a child exits non-zero, so every caller wraps every call and every wrapper rebuilds the same fields out of the error. Here a non-zero exit is a value. The promise rejects only when no process ran: the executable did not resolve, or the spawn failed.

What it does

  • Tells you which binary ran. whichSync returns the path and the PATH entry it came from. "Which node was that" is the first question of every build-differs-between-machines investigation and no resolver in the layer answers it.
  • Bounds the run by default. timeout is finite, the kill is a ladder (SIGTERM, then SIGKILL), and the output written before the kill is kept — a CI timeout with the output discarded is undiagnosable. execa and tinyexec both default to no timeout at all.
  • Renders the same result three ways. format() for a person, toJson() for --json, toEvent() for an agent stream, all derived from one record — so a --json flag cannot report something the human output did not.
  • Zero dependencies. cross-spawn pulls three packages; execa pulls sixteen.

What it is not. execa's streaming API and its $`cmd` form are a different product and are out of scope — see spec.md. And the weight pitch in this layer is already taken: tinyexec is zero-dependency, has 119.5 M downloads a week, and was published 2026-09-03. The claim here is resolution and shape, not bytes.

Entry points

ImportWhat it gives youIn place of
bellpullrun() and the Result, plus resolution and the three projectionsexeca, tinyexec, nano-spawn
bellpull/whichbellpull's own resolver, not a drop-in: resolution alone, and the PATH entry that answeredisexe, path-key, npm-run-path
bellpull/node-whichthe drop-in for npm's which — same which() and .sync, same options; graded 5 / 5 by node-which's own suitewhich
bellpull/cross-spawnthe drop-in — same callable default, same .synccross-spawn
bellpull/pluginthe resolvers plugin host—

bellpull/which is a leaf: it loads two files and nothing that has ever heard of process. A program that only needs to find a binary does not pay for a spawner.

No shell, ever, to solve a Windows problem

npm on Windows is npm.cmd, and a .cmd is a script CreateProcess will not run. The tempting fix is { shell: true }, and it re-opens command injection: with a shell, an argument is text in a command line, so a & in caller-supplied data is another command.

bellpull does what cross-spawn does — it invokes cmd.exe itself, having quoted every argument for CommandLineToArgvW and escaped every cmd.exe metacharacter, with windowsVerbatimArguments so Node does not quote them a second time. shell: true is available, off by default, and documented as the injection surface it is.

src/escape.test.ts checks that by round-tripping a 37-vector injection corpus through simulations of both parsers, and by asserting the naive fix fails on the dangerous half of it first.

The resolvers plugin host

which is the part every environment does differently — nvm, asdf, pnpm, a devcontainer. A plugin contributes a search order as data, with no function anywhere in it:

register({
  name: 'asdf',
  contract: 1,
  resolvers: {
    asdf: { rank: -10, paths: ['{ASDF_DATA_DIR}/shims'], when: { envAny: ['ASDF_DATA_DIR'] } },
  },
});

A negative rank searches before PATH. A path must be absolute after {VAR} substitution, or it is refused at register() — a relative entry means a different directory every time the program runs from somewhere else, including one somebody else can write to. {VAR} is the only substitution: $VAR and ~ are not expanded, and are refused as relative.

  • rank — a number: negative searches before PATH, positive after it.
  • paths — one or more directories, each absolute or starting with a {VAR} that holds an absolute path.
  • when — optional. envAny: ['VAR', …] applies the resolver only while one of those variables is set; platform: ['linux', …] only on those process.platform values. Leave it out and the resolver always applies. Any other key under when is refused.
  • extensions — optional, Windows only: what to try in place of PATHEXT.

The same plugin as a file of its own — one module whose default export is a plain object, with no import, which is the shape check loads:

// asdf-plugin.mjs
export default {
  name: 'asdf',
  contract: 1,
  resolvers: {
    asdf: { rank: -10, paths: ['{ASDF_DATA_DIR}/shims'], when: { envAny: ['ASDF_DATA_DIR'] } },
  },
};

Check it before it ships:

npx bellpull check ./asdf-plugin.mjs
# in a clone of this repository, where dist/ is not committed:
npx turbo run build --filter=bellpull && node packages/bellpull/dist/cli.js check ./asdf-plugin.mjs
asdf — 1 resolvers
  asdf
asdf: ok

The census comes first and ok last; the resolvers are listed in the order they are searched. A refusal exits 1 with a code and the edit to make — E_PLUGIN_SCHEMA for a missing rank, a relative path, or a resolvers that is not an object keyed by name.

Migrating

From cross-spawn or npm's which, one import:

- import spawn from 'cross-spawn';
+ import spawn from 'bellpull/cross-spawn';
- import which from 'which';
+ import which from 'bellpull/node-which';

require('bellpull/cross-spawn') works too. Or let the codemod make the change: npx burgee migrate --dry-run lists every import it would rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes it (Migrate).

From execa there is no drop-in: run() is the alternative, and Coming from execa walks the change.

Compatibility

Both drop-ins are graded by the incumbent's own suite, run unedited through compat-oracle beside a control run against the incumbent itself. Measured on 2026-09-15:

control (cross-spawn itself)bellpull/cross-spawn
cross-spawn suite, macOS68 / 6868 / 68

What the 68 / 68 does not cover. On POSIX cross-spawn is a pass-through, so its suite never reaches the escaping or the cmd.exe branch at all. Measured: replacing escapeArgument with arg => `"${arg}"` — the injection bug in its purest form — leaves the row at 68 / 68. The Windows behaviour is covered by this package's own tests and by nothing else until a Windows runner exists.

Benchmarks

Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.

Graded by the incumbent's own test suite:

suitepassing
cross-spawn68 / 68
execa0 / 1360
which5 / 5

Weight, installed and tree-inclusive: 98,062 bytes against 765,553 for the incumbents it replaces — a ratio of 0.1281 (measured).

For agents

  • One result, three readers. format() renders it for a person, toJson() for --json, toEvent() for an agent stream — one record, so the three cannot disagree.
  • A non-zero exit is a value, not an exception: result.ok is false, and the promise rejects only when no process ran at all.
  • result.executable says which binary ran, and from which PATH entry.
  • A resolver plugin can be checked before it ships. npx bellpull check ./asdf.mjs validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. The whole plugin and the report are under The resolvers plugin host.
  • The docs are machine-readable at bellpull.interlace.tools/llms.txt and llms-full.txt.

API

The entry points are under Entry points; every export, with its types, is on bellpull.interlace.tools.

Where it sits

Plugins register under the resolvers key, against the one schema the whole family shares.

burgee, caique, oxmutate build on it, and it builds on nothing in this family.

The family

Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the layers.

No package declares a dependency from outside the family; react and react-reconciler are optional peers of controlroom, which npm does not install; vitest is an optional peer of oxmutate, which npm does not install. package-shape-lock holds that for every manifest, and independence-install-lock installs each package alone and finds nothing but the family packages it declares.

Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which npm view <package> dist.attestations shows. trusted-publishing-lock keeps both true.

PackageWhat it isMigrates from
burgeeThe CLI framework: one declaration, every surfacecommander, yargs and meow
roundelColour: one output policy, semantic tokens, a themechalk, colorette, kleur, picocolors, supports-color and yoctocolors
flagstaffThe frame loop: spinners, progress, boxes and tablesora, log-update, boxen and cli-table3
caiquePrompts that are flags first, and never hang@inquirer/core
linegaugeMeasuring, wrapping, truncating and slicing styled textstring-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line
paratextHyperlinks, images, title, clipboard and notificationsansi-escapes, terminal-link and term-img
seniorityConfiguration precedence and discovery, with provenancecosmiconfig, lilconfig, dotenv and rc
closeoutExit handlers, terminal restore and a bounded shutdownsignal-exit, exit-hook, restore-cursor, cli-cursor and onetime
bellpull (this package)Subprocesses, and which executable actually rancross-spawn and which
controlroomKeyboard-driven terminal screens, inline or full-screenink
oxmutateMutation testing on the oxc engine, inside your own VitestStryker

Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.

Contributing

Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.

Licence

MIT © Ofri Peretz — see LICENSE.

On this page