burgee
Concepts

Agent surfaces

One declaration projected into every form a caller reads — help, the --json envelope, --schema, an MCP server, completions — and a program that stops and says what to run instead of prompting an agent.

What it is

A burgee command is declared once, as data. Every surface a caller reads is projected from that declaration rather than written beside it:

SurfaceForWhat it is
--helpa personthe rendered manifest; --help --json is the same as a document
--jsona script, an agentone envelope on stdout: { ok, data, meta } or { ok: false, error }
--schemaan agent, before it callsthe program as data: commands, options, effects, the exit-code table, a JSON Schema per command
--mcpan MCP clientthe same commands as MCP tools over stdio
completion <shell>a shella static script for bash, zsh, fish, pwsh or fig
--explain <option>anyone asking "why this value?"the configuration record for one option

Your CLI is an agent tool has each surface in full: --schema and its character budget, what becomes an MCP tool, how tool arguments map back to flags, the client configuration, and completions. This page is the model behind them.

Why it exists

An agent driving a CLI through a shell pays in calls, tokens and misreads: it scrapes help text, guesses which output line is the result, and waits on a prompt nobody will answer. When each surface is written by hand beside the command, they drift — the help names a flag the schema does not, the MCP tool takes an argument the command refuses. Projecting all of them from one declaration leaves them nothing to drift from.

The rules

The envelope is one document on stdout. Success is {"ok":true,"data":<what run returned>,"meta":{"provenance":{…}}}, with meta.changed for an idempotent command. Failure is {"ok":false,"error":{"code","message","hint"?,"fix"?}}, also on stdout, with stderr empty. --json counts only before a --. Exit codes and errors has the failure side.

effects is required on burgee's own API. defineCommand refuses a runnable command without read_only, idempotent, non_idempotent or withheld. It becomes MCP's readOnlyHint, idempotentHint and destructiveHint, and withheld keeps a command out of tools/list. On burgee/commander and burgee/yargs, a command that declared nothing is listed as effects: 'undeclared' with no hints (maps effects to the hints an agent reads, never defaulting destructive to silence (N6) in mcp.test.ts).

An MCP result is the --json envelope, byte for byte. A tool call runs the command as a --json caller would, so an agent on MCP and one shelling out see one payload (returns the --json envelope as the tool result, byte for byte (N4)).

An agent is detected, not assumed to be a terminal. detectAgent reads AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_THREAD_ID and GEMINI_CLI; the first one set names ctx.agent. ctx.interactive is FORCE_TTY=1, or a terminal on stdout with no agent (agent detection, not just isTTY (N12) in agent.test.ts). caique's prompts ask roundel's interactive(), which reads the same five variables and also treats CI, and a closed stdin, as nobody there (the output policy has both rules).

Never prompt an agent. burgee draws no prompt itself. A handler that would ask reads ctx.interactive and, when nobody can answer, calls ctx.actionRequired({ reason, message, next }): the run ends with exit 4 and an envelope carrying status: "action_required" and next[] — runnable commands with the program's name in front and the caller's --json carried. A prompt drawn with caique refuses instead of waiting and names the flag to pass (exit 2). Either way the caller gets a next step, not a hang (under --json: status, reason, message, runnable next[] carrying the caller's --json, hint; exit CANCELLED (4) in agent.test.ts).

Run it

tool.mjs
import { defineCommand, defineProgram, run } from 'burgee';

const program = defineProgram({
  name: 'tool',
  commands: [
    defineCommand({
      name: 'deploy',
      description: 'Deploy a build',
      effects: 'non_idempotent',
      options: {
        target: { type: 'string', required: true, description: 'where to deploy' },
        yes: { type: 'boolean', description: 'skip the confirmation' },
      },
      run: (ctx) => {
        if (!ctx.options.yes && !ctx.interactive) {
          ctx.actionRequired({
            reason: 'confirm',
            message: `deploying to ${ctx.options.target} needs confirmation`,
            next: [{ command: `deploy --target ${ctx.options.target} --yes`, when: 'once you have reviewed the plan' }],
          });
        }
        return { target: ctx.options.target, agent: ctx.agent ?? null };
      },
    }),
  ],
});

await run(program);

Nobody to confirm, so the program stops and says what to run:

CLAUDECODE=1 node tool.mjs deploy --target prod --json
{"ok":false,"status":"action_required","reason":"confirm","message":"deploying to prod needs confirmation","next":[{"command":"tool deploy --target prod --yes --json","when":"once you have reviewed the plan"}],"error":{"code":4,"message":"deploying to prod needs confirmation"}}
node tool.mjs deploy --target prod
action required (confirm): deploying to prod needs confirmation
next:
  tool deploy --target prod --yes    once you have reviewed the plan

The next command, run by the agent:

CLAUDECODE=1 node tool.mjs deploy --target prod --yes --json
{"ok":true,"data":{"target":"prod","agent":"claude-code"},"meta":{"provenance":{"target":{"source":"flag","location":"--target"},"yes":{"source":"flag","location":"--yes"}}}}

What an agent reads before it calls, from the same declaration:

node tool.mjs --schema deploy --field inputSchema
{"type":"object","properties":{"target":{"type":"string","flag":"--target","description":"where to deploy"},"yes":{"type":"boolean","flag":"--yes","description":"skip the confirmation"}},"required":["target"],"additionalProperties":false}
node tool.mjs --schema deploy --field effects
"non_idempotent"

Where the rules live

On this page