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 --schemaPrints 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.
| Key | What it says | Read from |
|---|---|---|
clispec | "0.3" | — |
version | the program's version, always | defineProgram({ version }), else the owning package.json |
errors | every kind a run can fail with: { kind, exit_code, retryable, description } | ExitCode, and each defineError class |
global_args | the flags every command takes | the rows root help prints under Global options: |
output | { "tty": "text", "piped": "text", "formats": [...] }: text unless asked for, even under a pipe | — |
a command's example | its first example as { "args": [...] }, the program's name dropped | examples |
a command's cardinality, output_kind, media_type | how much comes back, and what it is | output: { cardinality, kind, mediaType } |
a command's stdout_schema | what --json prints: the envelope, with your result as its data | output.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 --mcpServes 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,
effectsis required.defineCommandrefuses a runnable command without it, so every tool carries MCP'sreadOnlyHint,idempotentHintanddestructiveHint, generated from the declaration.'withheld'serves the command to people and keeps it out oftools/list. - On
burgee/commanderandburgee/yargs, a command that declared nothing is still a tool. Neither incumbent has a notion of effects, so it is listed withannotations: { 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 staleAn 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, figCommands, 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.
What an agent sees
Recorded sessions from B1, the agent benchmark — the same five tasks against one program built on burgee and on commander, with every turn and every number taken from runs on main.
Concepts
How the burgee family works, on its own terms: the ideas every package shares, the exact rules behind them, and the tests that hold each rule.