burgee

Your CLI is an agent tool

Every burgee program answers --schema and --mcp from its one declaration — no tool definitions, no second interface.

A burgee program is data first. The same manifest that renders --help answers two questions an agent asks before anything else.

--schema — the program as data

mytool --schema

Prints the commands, their arguments and options (with choices, defaults and which are required), each command's effects, its examples, and a JSON Schema per command. It reads the manifest and nothing else: no config file, no network, no handler runs (N8), so it is safe to call first.

The document shows each option's default and env var, but not which of them a run used. So just before commands it carries one line naming the flag that answers that: "explain":"--explain <option> on a command says where its value came from: default, env, config or flag". Agents that read --schema without it spent three to eight turns probing the env var through shell calls (D-20261009-b1-totals-and-explain). One command's schema (mytool deploy --schema) does not carry it, and neither does --schema on burgee/commander or burgee/yargs, which do not parse --explain.

mytool schema is the same request spelled as a subcommand — schema deploy and schema deploy --field options.region print exactly what the flag forms print — because that is where clispec.dev and the agents that follow it look. A program whose own schema command exists, or whose root command takes arguments, keeps the word: burgee answers only the flag there. The root --help names --schema.

It is also a clispec v0.3 document

The same document is a The CLI Spec v0.3 document (its schema): the keys clispec defines sit beside burgee's own, and schemaVersion stays 1, because none of burgee's keys changed meaning. Each is read from what the program already declares.

KeyWhat it saysRead from
clispec"0.3"—
versionthe program's version, alwaysdefineProgram({ version }), else the owning package.json
errorsevery kind a run can fail with: { kind, exit_code, retryable, description }ExitCode, and each defineError class
global_argsthe flags every command takesthe rows root help prints under Global options:
output{ "tty": "text", "piped": "text", "formats": [...] }: text unless asked for, even under a pipe—
a command's exampleits first example as { "args": [...] }, the program's name droppedexamples
a command's cardinality, output_kind, media_typehow much comes back, and what it isoutput: { cardinality, kind, mediaType }
a command's stdout_schemawhat --json prints: the envelope, with your result as its dataoutput.schema, else fields
defineCommand({
  name: 'list',
  effects: 'read_only',
  examples: [{ command: 'mytool list --json' }],
  output: { cardinality: 'bounded', schema: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
  run: () => [{ id: 'a' }], // TypeScript holds this to output.schema
});

A runnable command with no output.cardinality makes burgee dev print a warning, never an error: clispec has no safe default for how much comes back. clispec's checker scores the reference demo 24 / 24 (Benchmarks). Not carried: the burgee/commander and burgee/yargs documents, whose --schema is built where their weight budgets have no room, and the summary printed over the budget, which lists commands without what clispec requires of one.

--mcp — the program as MCP tools

mytool --mcp

Serves the same manifest as MCP tools over stdio — JSON-RPC 2.0, four methods (initialize, ping, tools/list, tools/call), no dependency outside the burgee family. A tool call runs the command exactly as a --json caller would; the result is that envelope, byte for byte, so an agent reading your CLI over MCP and one shelling out with --json see one payload.

What becomes a tool

Every command that runs and is not hidden, named by its path — config get is config_get, and a program whose root command runs (a single run(defineCommand(…)), or a commander program with a root .action()) is named after the program. What keeps a command out is a word its author wrote, never silence:

defineCommand({
  name: 'deploy',
  effects: 'non_idempotent', // read_only | idempotent | non_idempotent | withheld
  // …
});
  • On burgee's own API, effects is required. defineCommand refuses a runnable command without it, so every tool carries MCP's readOnlyHint, idempotentHint and destructiveHint, generated from the declaration. 'withheld' serves the command to people and keeps it out of tools/list.
  • On burgee/commander and burgee/yargs, a command that declared nothing is still a tool. Neither incumbent has a notion of effects, so it is listed with annotations: { effects: 'undeclared' } and no hints — and MCP's defaults for absent hints (not read-only, destructive) are the reading a client applies. Declare it with .effects('read_only'), or keep it out with .effects('withheld').

Arguments

A tool's inputSchema has one property per argument and option. An option's property is its declared key and names the flag it becomes — "dryRun": { "type": "boolean", "flag": "--dry-run" } — and a tool call is turned back into exactly that flag. A boolean sent as false is passed as --no-<name> when the option would otherwise be on (a true default or an environment variable) and left out otherwise, so an agent that spells out every false trips no relation. Under burgee/commander, a --no-color declared without a --color is the property noColor.

Results

The --json envelope, byte for byte. Under burgee/commander, data is what the action returned: an action that prints instead gives data: null, because commander actions return nothing. Return the value — .action(() => ({ lines })) — and it is data. Printed output becomes the tool result: whatever a handler writes to stdout during a call (console.log, process.stdout.write) is captured and follows the envelope as a second text item, never a stray line on the JSON-RPC stream, and what it writes to stderr stays on stderr.

The data comes back as data too. The result carries the envelope's data as structuredContent beside the text, or its error on a failure, and wraps anything that is not an object as { "result": … }, because the protocol requires an object. A command that declares output.schema lists it as the tool's outputSchema, wrapped the same way, so a client can check the result against it; a command that declares none lists none, rather than a schema guessed from one run. Text a handler printed instead of an envelope carries no structuredContent, and the text is unchanged either way.

A handler that throws, rejects or throws something that is not an Error is an error result, not a crashed server: isError: true, and the text is the { "ok": false, "error": … } envelope a --json caller gets on stdout. An AuthError carries code 5 ("auth" on the commander and yargs front ends) with its hint and fix, and the server answers the next call.

Cancellation and progress

Calls are served concurrently, so a client can stop one while it runs. notifications/cancelled aborts that call's ctx.signal with the reason cancelled, the same signal --timeout aborts: hand it to fetch, a child process or anything else that takes a signal, and the work stops. Its ctx.onExit handlers run once, and no response is sent for it. The server goes on serving.

ctx.progress(done, total?, message?) reports how far a command has got. When the client sent a progressToken with the call, each report is a notifications/progress carrying it; a value that does not rise above the last one is dropped, because the protocol requires progress to increase. Anywhere else ctx.progress does nothing, so call it unconditionally:

run: async ({ signal, progress }) => {
  for (const [at, file] of files.entries()) {
    await upload(file, { signal });
    progress(at + 1, files.length, file);
  }
}

A bar from flagstaff/progress does the same on its own: inside a call that asked for progress, it sends its count, with its static line (12/30 files · 40%) as the message, and draws nothing into the result. Outside one it draws as it always has. The root flagstaff entry's progress always draws, so the bridge costs nothing to a program that never serves MCP.

Client configuration

For Claude Desktop, Claude Code, Cursor or any stdio client, register the program as a server — nothing else to write. --mcp --print-config prints the block for the program you run it on: its name, and the command that starts it. A program started as one of its package's bins is started by that name, which is how an installed CLI is found on PATH; one started as node <file> is started that way, with the file's full path. On Windows, where npm installs a bin as a .cmd shim that a client cannot start directly, the bin is started through "command": "cmd", "args": ["/c", "demo-burgee", "--mcp"]. For the reference demo, run as its bin demo-burgee on Linux or macOS:

{
  "mcpServers": {
    "demo": {
      "command": "demo-burgee",
      "args": [
        "--mcp"
      ]
    }
  }
}

Paste it into the client's configuration as it is. The block above is that command's output, and a test starts the server from it and completes a tool call.

On burgee/commander and burgee/yargs, a program that declares its own --schema or --mcp option keeps it, and burgee leaves it alone: the surfaces are additive, never a change to a program that already exists. On burgee's own API the names are reserved, and defineCommand refuses an option that redefines one.

--skill — the program as an Agent Skill

mytool --skill                        # SKILL.md on stdout
npx burgee skill dist/index.js        # SKILL.md and AGENTS.md, beside the bin
npx burgee skill dist/index.js --check  # in CI: exit 1 when the committed files are stale

An Agent Skill, projected from the same declaration: front matter with a name and a description within the specification's limits, then what the program is for, the contract an agent relies on (--json and its envelope, --explain, every exit code with its error kind), and one line and the first example per command. It is small on purpose: a command's options are behind mytool --schema <command>, which the skill names and never inlines, so an agent pays for one command's contract when it uses that command.

burgee skill writes SKILL.md and AGENTS.md (the same body, no front matter) into the directory of the bin your package.json names, unless --out says where. When an AGENTS.md sits beside the binary or in the directory above it, the root help's line for agents names it; without one, the line is unchanged. A root that declares its own --skill option keeps it.

completion <shell> — static unless you ask

mytool completion zsh > "${fpath[1]}/_mytool"   # also: bash, fish, pwsh, fig

Commands, subcommands, options and choice values, with descriptions where the shell shows them — generated from the same manifest, as plain shell. Pressing TAB does not execute the program, with one exception you opt into: an option or a positional that declares complete has its values asked for at TAB time, through mytool __complete. Each script is pinned by a snapshot and exercised by its real shell in CI. The templates live in burgee/completions and load only on that command, so a program pays for them when it prints a script, never at startup.

A completer is called as complete(partial, context), and context is the rest of the line before the cursor. It carries the command path, the options already typed (coerced the way a run coerces them), the positionals, and raw for any value that failed coercion. So one value can depend on another (yargs #793):

defineCommand({
  name: 'deploy',
  options: {
    remote: { type: 'string' },
    branch: { type: 'string', complete: (partial, { options }) => branchesOf(String(options.remote ?? 'origin')) },
  },
  arguments: [{ name: 'service', complete: () => services() }],
  effects: 'non_idempotent',
  run: ({ options, args }) => deploy(args.service, options),
});

The line is unfinished, so nothing is required, no relation is checked, and no env, config or default is read on TAB. A completer that takes one parameter is called as it always was, and a completer that throws offers nothing.

--explain <option> — where a value came from

Every value a command sees comes from exactly one place, in one fixed order: flag > env > config file > package.json field > default. --explain says which, and what it beat:

$ mytool deploy --explain region
region = "eu-1"   from config file ./mytool.config.json
         candidates: flag --region (unset), env MYTOOL_REGION (unset), default "us-1"

Every command that runs lists --explain <option> in its help, and the root help's For agents: line names it beside --schema and --json. So do the --schema document and the hint on an unknown command that matches nothing. The same information rides under --json as meta.provenance, per option — for an agent, the difference between one call and five. envPrefix and config: true on defineProgram opt a program into env names and config discovery; nothing is read that the running command did not declare.

Stopping instead of prompting

ctx.interactive is true only with a terminal on stdin, no CI, and no detected agent (AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_THREAD_ID, GEMINI_CLI), terminal or not; FORCE_TTY=1 overrides. It is roundel's interactive(), the same rule caique's prompts ask. A handler reads ctx.interactive and ctx.agent, and where it would have prompted it stops with what the caller must do:

if (!ctx.interactive) ctx.actionRequired({
  reason: 'confirm',
  message: 'deploying to prod needs confirmation',
  next: [{ command: 'deploy --target prod --yes', when: 'once you have reviewed the plan' }],
});

The envelope carries status: "action_required", the reason, and next[] as runnable commands with the program in front and the caller's own --json carried; the exit code is 4. An idempotent command reports changed: true | false, and --schema summarises above a character budget with <command> --schema to drill into one.

On this page