Changelog
Every release of bellpull, newest first, from its CHANGELOG.md — what changed and the pull request it came from.
0.4.4
Patch Changes
- #738
3db5125Thanks @ofri-peretz! -shebangCommand's doc comment describes its trailing-space cases in words, so the generated API reference renders them without a code span that ends in a space.
0.4.3
Patch Changes
-
#730
3250edfThanks @ofri-peretz! - A#!line with a trailing space, or with two spaces afterenv, is read the wayshebang-commandreads it.#!/usr/local/bin/nodefollowed by a space used to name the interpreternodewith the space kept, a name that resolves nowhere, so on Windowsbellpull/cross-spawnandrun()sent that script tocmd.exeunder a command that does not exist.#!/usr/bin/env nodeused to name an empty interpreter, which then replaced the command. Code paths no input could reach are removed, and behaviour is otherwise unchanged.bellpull checkno longer carries a "(replaces …)" suffix it could never print, because it loads one plugin into an emptied registry. The Windows executability check no longer accepts a symbolic link from astatSync, which follows links and never reports one, and no longer special-cases an emptyPATHEXTentry thatendsWith('')already accepts. The POSIX check no longer tests the caller's uid and gid forundefinedbefore comparing them with a stat's, which are always numbers. -
#674
e9f45d8Thanks @ofri-peretz! - README: family header, badges, install, migrating, the family table.Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub.
0.4.2
Patch Changes
- #604
0e7b1e8Thanks @ofri-peretz! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes.
0.4.1
Patch Changes
- #588
073037aThanks @ofri-peretz! - READMEs and package descriptions now match what each drop-in path is graded at. bellpull namesbellpull/node-whichas the drop-in for npmwhich(5 / 5) and no longer lists execa as a drop-in. paratext's ansi-escapes row is 4 / 4, with the CSI half implemented. seniority's rc row is 1 / 1, dotenv'sconfig()defaults toprocess.env, and lilconfig is 77 / 77. linegauge documentsambiguousIsNarrowandstripas shipped. No code changes.
0.4.0
Minor Changes
-
#490
8a02d68Thanks @ofri-peretz! -bellpull/node-whichis a drop-in replacement for node-which 7:which(cmd, opts)returns a promise andwhich.syncruns synchronously, with node-which'sall,nothrow,path,pathExtanddelimiteroptions and itsENOENTerror. It passes node-which's own test suite, 5 of 5.bellpull/whichis unchanged: it stays bellpull's own resolution API and never reads the process.require('bellpull/node-which')returns the function with.syncon it, asrequire('which')does, andburgee migratenow rewriteswhichto it.
Patch Changes
-
#474
1955419Thanks @ofri-peretz! - burgee plugins can hook two more stages.parseruns before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin mapsdtodeploy.shutdownruns once as the program exits, whether the command succeeded or failed. The familyschema.jsonshipped in every package now describes both stages. -
#522
f4be6a8Thanks @ofri-peretz! -require('bellpull/cross-spawn'),require('flagstaff/cli-table3'),require('burgee/yargs'),require('seniority/dotenv')andrequire('seniority/rc')now return what the incumbent'srequire()does — the function, the class, the factory, the object — instead of an ES module namespace. Each exports its default as'module.exports', which is what Node hands a CommonJS caller, and which yargs' own entry already does.const spawn = require('…'); spawn(…)threw before.
0.3.1
Patch Changes
- #508
1aae1e2Thanks @ofri-peretz! -<package> --helpand--versionanswer instead of crashing. The bin took its first argument as the plugin file to import, soroundel --helpfailed withCannot find module '…/--help'and exit 1.-h/--helpnow print usage and exit 0,-V/--versionprint the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2.
0.3.0
Minor Changes
- #507
b8e97dcThanks @ofri-peretz! - Runs on Node 20 and 22, not just 24+:engines.nodeis now^20.19.0 || >=22.13.0. Those are the first releases whererequire(esm)loads without a warning, so the CommonJSrequire()path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer callPromise.withResolvers, which Node 20 doesn't have.
Patch Changes
- #505
9800b43Thanks @ofri-peretz! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that.
0.2.3
Patch Changes
- #494
f7f6d4bThanks @ofri-peretz! - Each package'shomepageand README docs link now point at its own documentation site,https://<package>.interlace.tools, instead of a page on burgee's site. The oldburgee.interlace.tools/docs/packages/<package>URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (npm:closeout@^0.4; the 0.4.0 release left it at^0.3).
0.2.2
Patch Changes
- #465
acf98f3Thanks @ofri-peretz! - Each README now opens with the incumbent it replaces and the agent surface it serves (--json, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed.
0.2.1
Patch Changes
-
#454
b4584e7Thanks @ofri-peretz! - Every package's npmhomepagenow points at its page on the docs site,https://burgee.interlace.tools/docs/packages/<name>, and each README links it under the header. The keywords add what people and models search for:burgeegainscli-framework,argument-parser,subcommands,json-schema,mcp-server,model-context-protocol,ai-agent,llm,shell-completion,typescript,zero-dependency,commander-alternativeandyargs-alternative; the other eight gainagent,ai-agent,non-tty,jsonandzero-dependencywhere the package does that —zero-dependencyonly on the six that install nothing at all.burgee's README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185.caique's README no longer calls a released package pre-release. -
#442
bdaf364Thanks @ofri-peretz! - Every package now listsplugin,pluginsandextensiblein its npm keywords, because every package takes plugins through one shared contract.A plugin is a plain object, validated against the
schema.jsonthat ships in every package, and checked with the package's owncheckcommand. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The plugins page has a nine-layer example that every package'scheckaccepts in CI. -
#435
7888524Thanks @ofri-peretz! -schema.jsonnow describes every plugin host in the family.The one schema each package ships as its plugin contract used to cover only four hosts: roundel's
tokens, flagstaff'sglyphs,spinners,bordersandcomponents, paratext'scapabilities, and linegauge'swidths. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them:- bellpull
resolvers, including the absolute-path rule onpaths - caique
widgets - closeout
handlers, including the phases a plugin may use - seniority
sources, including the rank bounds - burgee
commands,hooksandenforce
Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (
static,run,read,handler) are described and required, but not typed, because JSON Schema can't say "function".flagstaff now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it.
- bellpull
-
#445
dac303eThanks @ofri-peretz! - Every package now declaressideEffectstruthfully, so bundlers can drop what you don't import.Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath:
import before after import { explain } from 'seniority'2,939 B 1,067 B import { decide } from 'caique'1,235 B 734 B import { strip } from 'linegauge'1,102 B 940 B import { once } from 'closeout'353 B 235 B flagstaff and roundel used to declare
false, but each ships acheckcommand whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load.
0.2.0
Minor Changes
-
#421
db3c59eThanks @ofri-peretz! - Every plugin host has acheckcommand.npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --jsonPRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a
checkcommand that shows it every way it can be seen, and the bar is measured. The first was built in all nine hosts; the second existed inflagstaffalone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against.Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point before and after the override, paratext shows a capability's
encodeand itsfallback, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a document rather than printing one, soburgee check --jsonis the form an agent that just wrote a plugin reads.They share one contract with the author, held identically across all nine:
- a readable report, contribution by contribution, with
okas the last line; - a refusal with a code from the family's vocabulary and a
fix, exit 1; E_NO_CONTRIBUTIONfor a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself;- exit 2 with no file.
Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken.
- a readable report, contribution by contribution, with
Patch Changes
-
#415
47b541aThanks @ofri-peretz! - The matrix suite no longer leaks aSIGTERM-ignoring child when a hook times out.Two of its fixtures are pinned open by a
setIntervaland one declinesSIGTERMon purpose — it is the control case for R2, the whole reasonstartDeadlinehas a second rung. That rung is a timer in the parent, so a parent that dies first never fires it: when a hook in the file timed out, vitest tore the worker down and left the child spinning. One was found hours later with its temp directory already deleted out from under it.The leak paid for itself in the wrong direction. Every timed-out hook left a process that made the next timeout likelier, which is why this suite read as merely load-sensitive (D-091) and degraded over a long session rather than flaking at random.
Both fixtures now carry a 60-second self-limit — far longer than any case here, whose deadlines are in the hundreds of milliseconds, and far shorter than until the machine is rebooted. The two
afterAllteardowns get the 60 s budget every other filesystem- and process-touching hook in the file already had.fixture-lifetime.test.tsreads the fixture sources and refuses a long-lived one without its own limit; it fails on the unfixed file.No change to
bellpull's published behaviour — this is the suite, not the package. -
#430
4d1b2b3Thanks @ofri-peretz! -checknow reports every refusal with its code and its fix, wherever it was raised.Some plugin files register themselves on import: they call
register()at the top of the module and export the result. Until now, when such a file was refused, the error was thrown insidecheck'simport(), before the onlytrythat turns aPluginErrorintoE_PLUGIN_SCHEMA: …plus afix:line. The author got the bare message on stderr, with no code and no fix. Now the whole ofcheckruns inside that one handler, so every refusal comes out the same way on every host.
0.1.1
Patch Changes
-
#373
a1f1d40Thanks @ofri-peretz! - Stage 2's artifact is nowspec.md, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point atspec.mdrather thandesign.md.No behaviour changes. The published tarballs do move, by two bytes per surviving reference —
design.mdis nine characters andspec.mdis seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107.
0.1.0
Minor Changes
-
#332
3ea38c3Thanks @ofri-peretz! -bellpullwas a reserved name exporting one string. It now runs programs.run(cmd, args, { runtime })returns aResult—{ ok, code, signal, stdout, stderr, duration, command, args, executable, timedOut }— for every outcome a process can have. A non-zero exit resolves withok: false; the promise rejects only where no process ran, which is a missing executable or a failed spawn.execathrows on a non-zero exit, so every caller wraps it and every wrapper rebuilds these same fields out of the error; this repository already had two of those, incompat-oracle/src/run.tsandvendor.ts, both reading a result through acatch.timeoutis finite by default (30 s) and the kill is a ladder —SIGTERM, thenSIGKILLafter a grace window — because a child that trapsSIGTERMmakes a single-signal timeout a timeout that times nothing out. Output written before the kill is kept: a CI timeout with the output discarded is undiagnosable.bellpull/whichresolves an executable and reports whichPATHentry answered, which is the open position in this layer —which+isexe+path-keyis 779 M downloads a week across three packages and none of them returns it, and neither zero-dependency rival in the spawn layer resolves at all. TwoPATHentries are refused rather than searched: an empty one, which POSIX reads as the working directory and is the oldest privilege-escalation trick there is, and a relative one, whose meaning changes with wherever the program was run from.searchPath()reports each skip with its reason rather than quietly doing less than its author expected.bellpull/cross-spawnis the drop-in, graded 68 / 68 bycross-spawn's own vendored suite against a control of 68 / 68 on the same machine (macOS, 2026-09-15). On POSIX it is a pass-through, becausecross-spawnis one — everything it is famous for is Windows-only, and a façade that "improved" on the pass-through would change the error a caller sees and the process tree. The Windows half invokescmd.exeitself withwindowsVerbatimArguments, having quoted each argument forCommandLineToArgvWand caret-escaped everycmd.exemetacharacter. It never setsshell: trueto solve a Windows problem;shellis available, off by default, and documented as the injection surface it is.bellpull/pluginhostsresolvers(plugin-contractR5a, PLAN 1.5) — how an executable is found, sincewhichis the part every environment does differently. A resolver is{ rank, paths, extensions?, when? }and carries no function at all, so it survives JSON and aplugin checkcan print a search order without running anything;{VAR}in a path is substituted from the environment, the way paratext templates an OSC payload. A path that is not absolute after substitution is refused atregister(), because a resolver's directories are searched ahead ofPATHand a relative one means a different directory every time the program runs from somewhere else. Shipsbellpull/schema.json, byte-identical to flagstaff's (R2).Zero dependencies on every entry point, and
bellpull/whichis a leaf that loads two files. Installed, tree-inclusive: 82,270 bytes against a ceiling of 714,984 forexeca+cross-spawn+which— a ratio of 0.1151, up from 0.0067 when the package did nothing, which is the honest direction.Not built, and said rather than implied:
execa's streaming API and its template-literal form are out of scope, and no number is claimed againsttinyexec, which is not installed in this workspace.
Patch Changes
-
#332
3ea38c3Thanks @ofri-peretz! -bellpull/cross-spawnreadsspawnoff the default import instead of a captured named binding, so a consumer undercommander's own mocks sees the mock.This is a compatibility requirement, not a style.
commander's suite doest.mock.method(childProcess, 'spawn', …)in roughly 23executableSubcommandcases. A named ESM binding is captured at import and never re-syncs withoutsyncBuiltinESMExports(), so a consumer calls the real thing instead: measured, wiringburgee/commanderto this package took it from 1360 / 1360 to ungradeable — an un-mocked spawn ran a real subcommand whose exit killed the test runner.A property read off the default import sees it.
cross-spawnstays 68 / 68 either way, so the change costs this package nothing and is the whole blocker between bellpull and its first consumer.ChildProcessandSpawnSyncReturnsare re-exported because a consumer that declared them itself would tripinline-implementation-lock, which matches a type-onlynode:child_processimport too. -
#332
3ea38c3Thanks @ofri-peretz! -bellpullfailed on Windows — the one platform it exists for. Twenty-five cases red onNode 24 · windows-latest, and it was the only package failing there. One of them was a real defect; the rest were tests that had only ever described POSIX.The defect.
run()resolved the command with a singlePATHEXTwalk whileparse()used two — the second with expansion disabled, which is how a file with no extension and a#!line is found at all. So on Windows a shebang script resolved for the parse and was then refused byrun()withNotFoundError: a command the package had already worked out how to run, rejected by the half that runs it. The walk is now one exported function,resolveExecutable, and both callers use it. Off WindowsPATHEXTexpansion is always[''], so the second attempt is the first and the divergence could not show up — which is how it survived a 68 / 68 grading againstcross-spawn's own suite.The tests.
which.test.tsbuilt aplatform: 'linux'runtime and then fed itmkdtemppaths from the host, so on Windows it joinedC:\…\aandC:\…\bwith:and read two drive letters as two relative entries. Policy cases (search order, refusals,PATHEXT,runPath) now take invented runtimes and are pure functions of them; filesystem cases take a runtime describing the actual machine, with fixtures namedtool.CMDon Windows so they are executable under that platform's own rule. Both branches ofexecutableByNamenow run somewhere.matrix.test.tsjoined its fixturePATHwith:under aprocess.platformruntime; it usesnode:path'sdelimiter.escape.test.tsasserted thatx; touch <marker>fires throughshell: true— on Windows there is notouchand;separates nothing, so the vector is now written for each platform's own shell, and a Windows-only block runs a hostile argument end-to-end through a realnode_modules/.bin/*.cmdshim, which is the first execution of the double-escape path anywhere.weight.test.tswas the joke. It measured the package withexecFileSync('npm', …), and on Windowsnpmisnpm.cmd, which Node has refused to spawn withoutshell: truesince the fix for CVE-2024-27980. The weight lock of the package that exists becausenpmisnpm.cmdbroke onnpmbeingnpm.cmd. It now spawns through this package's ownparse.The kill ladder is proven without a clock. The end-to-end cell handed
run()a 300 ms deadline and assertedSIGKILL, but the stubborn child installs itsSIGTERMhandler on its first executed line — so a deadline shorter thannode's cold start kills it by the default action and the run truthfully reportsSIGTERM. One cold start in twelve took 265 ms on an idle Mac, and macOS CI went red on exactly that.startDeadlineis exported and takes a structuralKillable, so both rungs are now asserted on fake timers — which signal at which tick, with no process and no scheduler. The end-to-end cells calibrate their deadline against a cold start measured on the runner they are running on, and each asserts the child's own output first, because that line is the proof it was armed.newLine: 'lf'is pinned for the build, sonpm pack's byte count is a fact about the package rather than about the operating system that compiled it.Weight moved 82,141 → 85,129 B against the same 714,984 B ceiling, ratio 0.1149 → 0.1191.
-
#339
f295630Thanks @ofri-peretz! -schema.jsonconstrains token names, because it was promising something no host honours.tokenswas described as any name to a#rrggbbcolour.roundel'svalidate()accepts ten semantic names —error,warn,ok,hint,muted,command,flag,value,heading,ground— and throws on everything else. So a plugin author doing exactly what their ownE_PLUGIN_SCHEMAerror tells them, comparing their object againstroundel/schema.json, got a green from the schema and"accent" is not a tokenfromregister(). Measured 2026-09-16 with{ accent: '[#336699](https://github.com/ofri-peretz/burgee/issues/336699)' }.The schema now carries
propertyNames.enum, andscripts/plugin-contract-lock.test.tspins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know.Every host ships a byte-identical copy of this file (
plugin-schema-lock.test.tsasserts it), which is why nine packages are listed. Only the keyroundelowns is constrained: describingwidgets,handlers,sources,resolversorcommandsin a file all eight hosts share is what made flagstaff start validating caique's key last time (PluginError: plugin.widgets.later: expected object, got boolean), and those stay inplugin-schema-lock'sUNDESCRIBEDlist with that reason.linegaugeis in the list for a different change:ceilings.json's R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the supersededget-east-asian-widthbar beside it with the count of entries that cleared it.