burgee
A CLI framework built on one declaration: help, --json, --schema, MCP, completions and types are projected from it, with exit codes and fix: lines an agent can act on. No dependency outside the burgee family. Drop-in paths for commander, yargs and meow.
burgee is a CLI framework built on one declaration. A command declares itself once, and
help, --json, --schema, an MCP server, shell completions and TypeScript types are all read
from it. An agent gets contracts rather than prose: exit 2 means rewrite the command, a
mistyped command comes back with a fix: line it can run, and --explain says where each
value came from. Five dependencies, all from the burgee family, and no dependency outside it.
Drop-in for commander and yargs: npx burgee migrate rewrites the imports to
burgee/commander and burgee/yargs, graded by each one's own test suite, and the same
program answers agents too.
A burgee is the small swallowtail flag a boat flies to say which club or fleet it belongs to — a flag of identity, not of instruction. That is what this framework does for a command-line program: a command declares itself once, and every surface is that declaration read by a different reader.
Install
npm install burgee
pnpm add burgee
yarn add burgee
bun add burgeeQuick start
npx burgee init my-cli && cd my-cli && npm installinit writes this file as cli.mjs, and a package.json that runs it. Nothing else.
#!/usr/bin/env node
// cli.mjs — the whole CLI
import { defineCommand, run } from 'burgee';
run(defineCommand({
name: 'greet',
description: 'Greet someone by name',
options: { name: { type: 'string', required: true, description: 'who to greet' } },
effects: 'read_only', // what running it does to the world; required, and what --mcp reads
run: ({ options }) => ({ greeting: `hello, ${options.name}` }),
}));$ node cli.mjs --name ada
greeting: hello, ada
$ node cli.mjs --json --name ada
{"ok":true,"data":{"greeting":"hello, ada"},"meta":{"provenance":{"name":{"source":"flag","location":"--name"}}}}
$ node cli.mjs # exit 2
error: missing required option --name
hint: pass --name <value>
usage: greet [options]
options:
--name <value> who to greet (required)One file. No build step, no config file, no directory convention. A test enforces that on every commit.
One declaration, every surface
defineCommand() ──▶ manifest ──┬──▶ human help
├──▶ --json one stable envelope
├──▶ --schema versioned, one document per surface
├──▶ --mcp an MCP server, generated
├──▶ completions bash · zsh · fish · pwsh
├──▶ TypeScript types
└──▶ docs + llms.txtNothing here needs keeping in sync, because nothing is written twice.
What is in the box
| Import | Gives you |
|---|---|
burgee | defineCommand(), run(), the exit-code contract and the JSON envelope. |
burgee/commander | The commander API, graded by commander's suite. |
burgee/testing | Run a command in-process and assert on its result — no spawning. |
burgee/brand | One brand declaration → flag, favicon, OG card, cover, lockup. The logo above is its own output. |
burgee/plugin | definePlugin(), validate(), CONTRACT and PluginError — the host, at the subpath every package in the family publishes its host at. |
burgee/program-schema.json | The JSON Schema every --schema document validates against. |
burgee/locale-pack-schema.json | The JSON Schema a locale pack is written against: every message id, with its English. |
Writing a plugin
A plugin is one module whose default export is a plain object. In full, with a command and a hook:
// acme-plugin.mjs
export default {
name: 'acme',
contract: 1, // required on a plain object; `definePlugin` from `burgee/plugin` stamps it for you
commands: [
{ path: ['audit'], description: 'Audit the tree', options: {}, effects: 'read_only', run: () => ({ findings: 0 }) },
],
hooks: {
// One hook per stage: parse, preRun, postRun, onError, shutdown. `filter.command` is a
// RegExp tested against the command's path, words joined by spaces (`deploy prod`);
// leave `filter` out and the hook fires for every command.
preRun: { filter: { command: /^publish/ }, handler: (ctx) => {} },
},
};A plugin's command is read by exactly the code a first-party one is read by, so the same
refusals apply: reserved option names, duplicate flags, a contributed path that is already
declared — and effects, which every runnable command declares: read_only, idempotent,
non_idempotent or withheld. An object that reaches use() without a contract is refused
rather than accepted on trust, because burgee's extension point shipped before it validated
anything.
Check it before it ships. --json returns the report as a document, and the exit code is the
verdict:
npx burgee check ./acme-plugin.mjs --json
# in a clone of this repository, where dist/ is not committed: build burgee and the five
# packages it imports, then run the built bin
npx turbo run build --filter=burgee
node packages/burgee/dist/cli.js check ./acme-plugin.mjs --json{"ok":true,"data":{"name":"acme","commands":[{"path":"audit","description":"Audit the tree","effects":"read_only"}],"hooks":[{"stage":"preRun","applies":"commands matching /^publish/"}]},"meta":{"provenance":{}}}data.name is the plugin, data.commands one row per contributed command, and data.hooks
one row per hook with its stage. A refusal exits 1 and carries data.refused instead —
{ code, message, fix }, where fix is the edit to make. ok agrees with the exit code, so
it is false here, and data.refused.fix says what to change:
{"ok":false,"data":{"refused":{"code":"E_PLUGIN_CONTRACT","message":"plugin \"acme\" declares no contract; …","fix":"add `contract: 1` — `definePlugin` from `burgee/plugin` stamps it"},"exitCode":1},"meta":{"provenance":{}}}The same object can carry other layers' keys, such as tokens for roundel or spinners for
flagstaff. burgee check hands each of them to that layer's own check and lists the result
under data.layers, one row per layer: { host, keys, run, exitCode, output }. The layer is
found where Node would resolve it from the plugin's directory and is run as a command. A layer
that is not installed gets a row with no exitCode, and its run is the npx command to use.
A layer that refuses makes the whole check refuse, with that layer's code and fix.
Speaking your user's language
Help, usage errors and hints can speak the user's language; --json, --schema, --mcp,
--format=agent, error kinds and exit codes never change with it, and a test in this package
fails if one does. A language is a plugin carrying locales, written against
burgee/locale-pack-schema.json, which lists every message
id with its English:
// fr-pack.mjs
export default {
name: 'fr',
contract: 1,
locales: {
fr: {
'usage.unknownOption': 'option inconnue {option}',
'help.usage': 'Utilisation :',
// A message with {count} takes the plural forms Intl.PluralRules selects for the locale.
'usage.stdinOnce': { one: '…{count} argument…', many: '…{count} d’arguments…', other: '…{count} arguments…' },
},
},
};Register it with program.use(pack). The locale is the program's locale run option, then
BURGEE_LOCALE, then LC_ALL, LC_MESSAGES and LANG, so LANG=fr_FR.UTF-8 finds fr-FR
and then fr. When an agent is detected, LANG is ignored, because an agent inherits it
without asking for it, and only the option or BURGEE_LOCALE applies. An id the pack leaves out
stays in English. npx burgee check ./fr-pack.mjs --json renders every message and gives each
locale's coverage (the ids translated, the ids left in English, and each plural message's forms
against the forms the locale selects), or refuses an unknown id, a placeholder the English lacks,
or a missing plural form. Your own text takes a
locale map, base language first, and help shows the user's language while --schema keeps the
base language:
defineCommand({ name: 'deploy', description: { en: 'Deploy the site', fr: 'Déployer le site' }, effects: 'non_idempotent', run })Migrating
Already on commander or yargs? Change one import:
- import { Command } from 'commander';
+ import { Command } from 'burgee/commander';- import yargs from 'yargs';
+ import yargs from 'burgee/yargs';Your code and your tests are unchanged.
Or let the codemod make that change, and the same one for chalk, ora, string-width, cross-spawn, signal-exit and every other import the family has a graded drop-in path for:
npx burgee migrate --dry-run
npx burgee migrateIt rewrites import specifiers and nothing else — the mocks in your tests included — for the
incumbents your package.json declares (--only and --skip change the choice). It leaves a
replacement that is not level yet alone with its grade, refuses a file it cannot rewrite whole,
moves an incumbent in every file or in none, and prints the install command to run next, each
family package pinned to the version that carries its drop-in. It exits 1 when anything was
refused, and the report's first line then says the run was partial and how many files moved —
Migrate.
Compatibility
Drop-in compatible with both incumbents, graded by their own test suites — 1,360 / 1,360 of commander's tests and 816 / 816 of yargs' on the compatibility page — with the pass rate published and ratcheting. A façade is never called "compatible" until its host's own suite passes 100%; below that the rate is published instead of claimed.
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 |
|---|---|
commander | 1360 / 1360 |
extra-typings | 369 / 369 |
meow | 146 / 148 |
yargs | 816 / 816 |
For agents
Every command answers the same declaration four more ways, with nothing written by hand:
-
--json— one stable envelope,{ ok, data, meta }, with the provenance of every option. -
--schema— the whole command tree as data, versioned, one document per surface. It validates againstburgee/program-schema.json, the JSON Schema the package publishes, so a CI job can pin a program's surface without writing its own idea of the shape. -
--mcp— the same manifest served as MCP tools over stdio; each tool'seffectsbecomes its MCP hints, andwithheldkeeps a command out of the list. -
Exit
2when the command was wrong, with ahint:— so an agent rewrites the command rather than retrying it. -
A failure names the next step. A
fix:when there is exactly one (the flag or command it was nearest to), otherwise the failing command's usage line and options, or, for an unknown command, the commands that exist. It is prose on stderr, anderror.usagein the--jsonenvelope. -
--explain <option>— where a value came from: flag, env, config or default. Every command's help lists it, the root help ends with one line for agents, and--schemaand the unknown-command hint name it too. -
-q/--quietsilences decoration, never data or a failure. Handctx.runtimeto what draws, and a caique spinner,log.infoornotewrites nothing;log.errorstill writes, and a prompt still asks:import { spinner } from 'caique/clack'; const spin = spinner({ runtime: ctx.runtime }); // silent under --quiet -
A plugin can be checked before it ships.
npx burgee check ./plugin.mjs --jsonreturns{ name, commands, hooks }asdata, ordata.refusedwith afixand exit 1 — the whole plugin and both documents are under Writing a plugin.
Your CLI is an agent tool has each surface; the docs themselves are at burgee.interlace.tools/llms.txt and llms-full.txt.
Status
burgee is published and working: the engine, the exit-code contract, the JSON envelope,
help from the manifest, plugins with hook filters, and a burgee/commander façade that
runs a real commander program. The dev loop, prompts, lazy commands and groups are not
here yet.
Roadmap, architecture and the 114-requirement floor: https://github.com/ofri-peretz/burgee
FAQ
Is burgee a commander alternative?
Yes — and a yargs alternative. It is drop-in compatible with both: change
import { Command } from 'commander' to import { Command } from 'burgee/commander' (or
yargs to burgee/yargs) and your code and tests are unchanged. Compatibility is graded by
each host's own test suite in CI, not asserted. The guides:
Switching from commander and
Switching from yargs.
How do I make my CLI usable by an AI agent?
Declare it with defineCommand() — or keep it on commander or yargs syntax through the
drop-in front ends. Every command then answers --json with one stable envelope,
--schema with the whole command tree as data, and exits 2 when the command was wrong
so an agent knows to rewrite it rather than retry. None of it is written by hand; it is
the declaration read by a different reader.
Your CLI is an agent tool has each surface.
How do I expose a CLI over MCP?
Run it with --mcp: the same manifest is served as MCP tools over stdio. Every runnable
command declares its effects — read_only, idempotent or non_idempotent, which become
MCP's hints, or withheld, which keeps it out of the tool list — so nothing reaches an
agent by accident. On burgee/commander and burgee/yargs, a command that declared
nothing is still listed, marked effects: 'undeclared'. Register it with any stdio client:
{ "mcpServers": { "mytool": { "command": "npx", "args": ["mytool", "--mcp"] } } }Does it have dependencies?
None outside the burgee family. burgee installs five packages from that family —
bellpull, closeout, linegauge, roundel and seniority — and each of those takes
nothing from outside it either: one repository, one release pipeline, one supply chain to
audit.
API
The entry points are under What is in the box; the reference, with every option and type, is at burgee.interlace.tools.
Where it sits
Plugins register under the commands, hooks, locales, formats keys, against the one schema the whole family shares.
oxmutate builds on it, and it builds on bellpull, closeout, linegauge, roundel, seniority.
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 (this package) | 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 | 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.