Commander CLI with Zod option validation, tested in-process: working versions (tested 2026-10-15)
A Commander subcommand CLI with variadic arguments, repeatable options, choices and Zod-backed argument parsers, made testable with exitOverride and configureOutput so node:test runs it in-process without spawning.
passed5 passed · 0 failed2026-10-15 14:57 UTC- Status
- passed
- Last passed
- 2026-10-15 14:57 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("commander-zod-cli") (install), or see them on GitHub.
{
"name": "commander-zod-cli",
"private": true,
"type": "module",
"bin": { "notes": "./src/bin.ts" },
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"commander": "15.0.0",
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "26.6.5"
}
}
#!/usr/bin/env -S node --import tsx
import { run } from "./cli.ts";
const r = await run(process.argv.slice(2));
process.stdout.write(r.stdout);
process.stderr.write(r.stderr);
process.exitCode = r.code;
import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
import { z } from "zod";
export type Note = { id: number; text: string; tags: string[]; priority: "low" | "normal" | "high" };
const AddOpts = z.object({
tag: z.array(z.string().regex(/^[a-z0-9-]+$/, "tags are lowercase kebab-case")).default([]),
priority: z.enum(["low", "normal", "high"]),
});
/** commander argParser that funnels a Zod schema into commander's own error reporting. */
function zodArg<T>(schema: z.ZodType<T>) {
return (value: string) => {
const r = schema.safeParse(value);
if (!r.success) throw new InvalidArgumentError(r.error.issues[0].message);
return r.data;
};
}
/**
* Builds the CLI around an injected store and output sinks so tests can run it in-process.
* exitOverride turns process.exit into a thrown CommanderError.
*/
export function buildCli(store: Note[], out: (s: string) => void, err: (s: string) => void) {
const program = new Command("notes")
.description("tiny notes CLI")
.version("1.0.0")
.exitOverride()
.configureOutput({ writeOut: out, writeErr: err });
program.command("add")
.description("add a note")
.argument("<text...>", "note text")
.option("-t, --tag <tag>", "tag (repeatable)", (v: string, prev: string[] = []) => [...prev, v])
.addOption(new Option("-p, --priority <level>", "priority").choices(["low", "normal", "high"]).default("normal"))
.action((words: string[], opts: unknown) => {
const parsed = AddOpts.safeParse(opts);
if (!parsed.success) program.error(`error: ${parsed.error.issues[0].message}`, { exitCode: 2, code: "notes.invalid" });
const note: Note = { id: store.length + 1, text: words.join(" "), tags: parsed.data!.tag, priority: parsed.data!.priority };
store.push(note);
out(`added #${note.id}\n`);
});
program.command("list")
.description("list notes")
.option("--json", "print JSON")
.option("--min <n>", "show at most n notes", zodArg(z.coerce.number().int().positive()))
.action((opts: { json?: boolean; min?: number }) => {
const notes = opts.min ? store.slice(0, opts.min) : store;
if (opts.json) out(JSON.stringify(notes) + "\n");
else for (const n of notes) out(`#${n.id} [${n.priority}] ${n.text}${n.tags.length ? " #" + n.tags.join(" #") : ""}\n`);
});
return program;
}
/** Runs argv (without node and script) and returns an exit code instead of exiting. */
export async function run(argv: string[], store: Note[] = []) {
let stdout = "", stderr = "";
const program = buildCli(store, (s) => (stdout += s), (s) => (stderr += s));
try {
await program.parseAsync(argv, { from: "user" });
return { code: 0, stdout, stderr, store };
} catch (e) {
if (e instanceof CommanderError) return { code: e.exitCode, stdout, stderr, store, errorCode: e.code };
throw e;
}
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { run, type Note } from "../src/cli.ts";
test("add collects variadic text, repeatable tags and a default priority", async () => {
const r = await run(["add", "buy", "milk", "-t", "home", "--tag", "errand"]);
assert.equal(r.code, 0);
assert.equal(r.stdout, "added #1\n");
assert.deepEqual(r.store[0], { id: 1, text: "buy milk", tags: ["home", "errand"], priority: "normal" });
});
test("choices() rejects an unknown priority with exit code 1", async () => {
const r = await run(["add", "x", "-p", "urgent"]);
assert.equal(r.code, 1);
assert.equal(r.errorCode, "commander.invalidArgument");
assert.match(r.stderr, /Allowed choices are low, normal, high/);
});
test("zod refinements surface through program.error with a custom exit code", async () => {
const r = await run(["add", "x", "-t", "Bad Tag"]);
assert.equal(r.code, 2);
assert.equal(r.errorCode, "notes.invalid");
assert.match(r.stderr, /kebab-case/);
});
test("list prints text or JSON and validates --min through a zod argParser", async () => {
const store: Note[] = [];
await run(["add", "one", "-p", "high"], store);
await run(["add", "two", "-t", "work"], store);
const text = await run(["list"], store);
assert.equal(text.stdout, "#1 [high] one\n#2 [normal] two #work\n");
const json = await run(["list", "--json", "--min", "1"], store);
assert.deepEqual(JSON.parse(json.stdout).map((n: Note) => n.text), ["one"]);
const bad = await run(["list", "--min", "zero"], store);
assert.equal(bad.code, 1);
assert.match(bad.stderr, /option '--min <n>' argument 'zero' is invalid/);
});
test("--version and missing arguments exit through exitOverride", async () => {
const v = await run(["--version"]);
assert.equal(v.code, 0);
assert.equal(v.stdout.trim(), "1.0.0");
const missing = await run(["add"]);
assert.equal(missing.code, 1);
assert.equal(missing.errorCode, "commander.missingArgument");
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
Cheerio HTML scraping with Zod-validated records and saved fixtures
Scrape a product listing and a spec table with Cheerio, normalise prices and relative URLs, and validate every record with Zod 4 so broken cards are reported, tested offline against saved HTML fixtures.
Croner cron jobs from a Zod-validated config, with timezone previews
Validate a job config with Zod 4 (cron pattern checked by croner, IANA timezone, unique names), preview upcoming runs across DST and month ends with croner nextRuns, and start protected jobs with maxRuns, tested offline.