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 bellpullQuick 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.
whichSyncreturns the path and thePATHentry it came from. "Whichnodewas 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.
timeoutis finite, the kill is a ladder (SIGTERM, thenSIGKILL), and the output written before the kill is kept — a CI timeout with the output discarded is undiagnosable.execaandtinyexecboth 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--jsonflag cannot report something the human output did not. - Zero dependencies.
cross-spawnpulls three packages;execapulls 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
| Import | What it gives you | In place of |
|---|---|---|
bellpull | run() and the Result, plus resolution and the three projections | execa, tinyexec, nano-spawn |
bellpull/which | bellpull's own resolver, not a drop-in: resolution alone, and the PATH entry that answered | isexe, path-key, npm-run-path |
bellpull/node-which | the drop-in for npm's which — same which() and .sync, same options; graded 5 / 5 by node-which's own suite | which |
bellpull/cross-spawn | the drop-in — same callable default, same .sync | cross-spawn |
bellpull/plugin | the 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 beforePATH, 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 thoseprocess.platformvalues. Leave it out and the resolver always applies. Any other key underwhenis refused.extensions— optional, Windows only: what to try in place ofPATHEXT.
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.mjsasdf — 1 resolvers
asdf
asdf: okThe 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, macOS | 68 / 68 | 68 / 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:
| suite | passing |
|---|---|
cross-spawn | 68 / 68 |
execa | 0 / 1360 |
which | 5 / 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.okisfalse, and the promise rejects only when no process ran at all. result.executablesays which binary ran, and from whichPATHentry.- A resolver plugin can be checked before it ships.
npx bellpull check ./asdf.mjsvalidates 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 Theresolversplugin 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.
| Package | What it is | Migrates from |
|---|---|---|
| burgee | The CLI framework: one declaration, every surface | commander, yargs and meow |
| roundel | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors |
| flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
| caique | Prompts that are flags first, and never hang | @inquirer/core |
| linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
| paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
| seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
| closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
| bellpull (this package) | Subprocesses, and which executable actually ran | cross-spawn and which |
| controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |
| oxmutate | Mutation testing on the oxc engine, inside your own Vitest | Stryker |
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.