DEV Community

Cover image for One Import Moves a commander CLI to burgee. --json Still Says null.
Ofri Peretz
Ofri Peretz

Posted on Originally published at ofriperetz.dev

One Import Moves a commander CLI to burgee. --json Still Says null.

I changed one line in a commander CLI:

- import { Command } from "commander";
+ import { Command } from "burgee/commander";
Enter fullscreen mode Exit fullscreen mode

Help, output, errors and exit codes stayed byte-identical on seven invocations. The same file also answers --schema, --mcp and completion <shell>. And --json answers "data":null.

One import describes the CLI to an agent; answering takes a return. I maintain burgee; everything below ran on commander@15.0.0 and burgee@0.11.1.

The program

Plain commander, ESM, as cli.commander.js:

#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { Command } from "commander";

const program = new Command();
program
  .name("lines")
  .description("Count and rank the lines in a text file")
  .version("1.0.0");

program
  .command("count")
  .description("count the lines in a file")
  .argument("<file>", "file to read")
  .option("--skip-blank", "ignore empty lines")
  .action((file, opts) => {
    let lines = readFileSync(file, "utf8").trimEnd().split("\n");
    if (opts.skipBlank) lines = lines.filter((l) => l.trim() !== "");
    console.log(`${lines.length} lines in ${file}`);
  });

program
  .command("longest")
  .description("print the longest lines in a file")
  .argument("<file>", "file to read")
  .option("-n, --top <count>", "how many lines to show", "3")
  .action((file, opts) => {
    const lines = readFileSync(file, "utf8").trimEnd().split("\n");
    const top = lines
      .sort((a, b) => b.length - a.length)
      .slice(0, Number(opts.top));
    for (const line of top)
      console.log(`${String(line.length).padStart(4)}  ${line}`);
  });

program.parse();
Enter fullscreen mode Exit fullscreen mode

The swap

npm install commander@15.0.0 burgee@0.11.1   # or: yarn/pnpm/bun add
npm pkg set type=module
cp cli.commander.js cli.js
printf 'alpha\n\nbravo charlie\ndelta echo foxtrot golf\n\nhotel\n' > sample.txt
Enter fullscreen mode Exit fullscreen mode

All four work, as does require(). TypeScript's legacy node resolver cannot see the subpath. After the import swap in cli.js:

$ diff <(node cli.commander.js --help) <(node cli.js --help) && echo identical
identical
Enter fullscreen mode Exit fullscreen mode

Same for count --help, both commands, a missing argument and a typo. Usage errors still exit 1; burgee's native exit 2 for "rewrite the command" does not come with the swap. Once commander leaves dependencies, the declared Node range goes from >=22.12.0 to ^20.19.0 || >=22.13.0. The swapped file ran on Node 20 and 22; only 22.12.x drops out.

What answers with no other edit

--schema: the command tree as data, a JSON Schema per command:

$ node cli.js --schema | jq -c '.commands[0].inputSchema'
{"type":"object","properties":{"file":{"type":"string","description":"file to read"},"skipBlank":{"type":"boolean","flag":"--skip-blank","description":"ignore empty lines"}},"required":["file"],"additionalProperties":false}
Enter fullscreen mode Exit fullscreen mode

Errors under --json: typed, with the fix:

$ node cli.js count sample.txt --skp-blank --json
{"ok":false,"error":{"code":"commander.unknownOption","message":"unknown option '--skp-blank'","fix":"--skip-blank"}}
Enter fullscreen mode Exit fullscreen mode

MCP discovery: every command becomes a tool, and an undeclared one says so instead of guessing a hint:

$ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node cli.js --mcp | jq -c '.result.tools[] | {name, annotations}'
{"name":"count","annotations":{"effects":"undeclared"}}
{"name":"longest","annotations":{"effects":"undeclared"}}
Enter fullscreen mode Exit fullscreen mode

.effects("read_only") on count yields readOnlyHint: true. That call is a one-way door: real commander throws a TypeError.

Completions: completion <shell> for bash, zsh, fish, pwsh and fig, as static output.

Where one import stops

$ node cli.js count sample.txt --json
6 lines in sample.txt
{"ok":true,"data":null,"meta":{"provenance":{}}}
Enter fullscreen mode Exit fullscreen mode

burgee's envelope wraps what an action returns. commander discards return values, so commander programs rarely write one. Under --mcp, that printed line lands on the JSON-RPC stream, where no client can parse it.

The second edit, in cli.answering.js: return the result, and keep prose off stdout when a machine asked:

+const machine = ["--json", "--mcp"].some((f) => process.argv.includes(f));
+const say = (text) => {
+  if (!machine) console.log(text);
+};
 ...
-    console.log(`${lines.length} lines in ${file}`);
+    say(`${lines.length} lines in ${file}`);
+    return { file, lines: lines.length };
Enter fullscreen mode Exit fullscreen mode
$ node cli.answering.js count sample.txt --json
{"ok":true,"data":{"file":"sample.txt","lines":6},"meta":{"provenance":{}}}
Enter fullscreen mode Exit fullscreen mode

Humans still see 6 lines in sample.txt. MCP tool calls get the same envelope. The argv test is crude. 0.11.1's façade has no public "machine asked?" flag yet.

Two defects, fixed in 0.11.1

Drafting on 0.9.2, I hit two defects in what the swap adds. An MCP call ignored the schema's "flag":"--skip-blank" and passed --skipBlank, which the program refused. Completions offered --no-skip-blank and --no-version, likewise refused. I fixed both in 0.11.1. The same call now answers:

$ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"count","arguments":{"file":"sample.txt","skipBlank":true}}}' | node cli.answering.js --mcp | jq -r '.result.content[0].text'
{"ok":true,"data":{"file":"sample.txt","lines":4},"meta":{"provenance":{"skipBlank":{"source":"flag"}}}}
Enter fullscreen mode Exit fullscreen mode

Still open: an action's throw stays uncaught under --json.

What the grade covers

commander's own suite (v15.0.0, 110 files, unmodified) runs against burgee/commander next to a control on real commander. Re-run for this article: 1360 / 1360, control 1360 / 1360. That grade measures what you keep. The two defects sat in what you gain, which commander's tests cannot see.

When commander alone is the right size

On disk, commander 15.0.0 unpacks to 207,368 bytes; burgee 0.11.1 and the five sibling packages it installs, to 1,266,628. In a bundle, burgee's own lighter-than-commander gate reads not met, 1.514×. If no agent will call your CLI, commander is the smaller choice.

burgee vs commander · compatibility · source

Which command in your CLI prints something an agent currently scrapes with a regex, and what would returning it instead break?

Top comments (0)