Spec-driven CLI core — author a verb once, project it everywhere.
A verb's input and output are Zod schemas. JSON Schema (z.toJSONSchema) is the
projection IR, and every surface is a pure projection of the one spec: the CLI
parser and --help, the MCP tool, the Anthropic tool-use schema, and the
OpenAPI operation. There is no codegen and no build step — the schema is the
single source of truth, so the surfaces can't drift.
VerbSpec (Zod — canonical, runtime + static)
└─ z.toJSONSchema ──▶ JSON Schema (the interchange IR)
├─ toHelp / parseArgs ──▶ argv → typed input, `--help`
├─ toMcpTool ──▶ { name, description, inputSchema }
├─ toAnthropicTool ──▶ { name, description, input_schema }
└─ toOpenApiOperation ──▶ POST /{id} (request/response schemas)
npm install @bounded-systems/verbspec zodzod is a peer dependency (^3.25 || ^4).
import{z}from"zod";import{defineVerb,parseArgs,dispatch,toMcpTool,toAnthropicTool,toOpenApiOperation,render,}from"@bounded-systems/verbspec";// Author the verb once.constgreet=defineVerb({id: "greet",summary: "Greet someone by name",actor: "work",positionals: ["name"],input: z.object({name: z.string(),loud: z.boolean().default(false)}),output: z.object({message: z.string()}),run: ({ name, loud })=>({message: loud ? `HELLO ${name}!` : `hello ${name}`}),});// CLI: argv → validated input → run → printed result.constresult=awaitdispatch({ greet },["greet","Ada","--loud"]);if(result.kind==="ok")console.log(render(result.output));// { "message": "HELLO Ada!" }// MCP / Anthropic / OpenAPI: the same schema, seen from other sides.toMcpTool(greet);// { name, description, inputSchema }toAnthropicTool(greet);// { name, description, input_schema }toOpenApiOperation(greet);// POST /greetparseArgs carries the CLI-isms (positionals, boolean flags, repeated and
comma-split array values); the Zod parse is the only validation. The
MCP/OpenAPI/Anthropic projections take structured JSON and consume only
input/output — never the CLI-only render/exitCode/warnings hooks.
input declares every field a verb accepts. positionals only selects which
of those fields are read as bare arguments instead of --flags, in order — it
never declares a field on its own:
positionals: ["name"],input: z.object({name: z.string(),loud: z.boolean().default(false)}),// `greet Ada --loud` → name from the positional, loud from the flagTwo consequences, both enforced rather than silently absorbed:
- A field not listed in
positionalsis flag-only. Declaringslug: z.string().optional()withpositionals: []meansmyverb somevalueis an error, not a filter — pass--slug somevalue. Bare arguments are never auto-bound to a leftover field, because a mis-bound argument that "works" silently is how a scoped command becomes an unscoped one. - A name in
positionalsthat no input field declares is a spec error. It would bind a value thatinput.parsethen strips as an unknown key, soparseArgsrejects the spec instead.
More generally: any argument that maps to nothing the verb declares — an unknown flag, an extra positional, or a positional naming no field — throws. Nothing falls through to a default.
Every boolean input gets both spellings: --loud sets it true, --no-loud sets
it false. So a boolean that is on by default can be turned off from the command
line with the one field that declares it:
input: z.object({changedOnly: z.boolean().default(true)}),// `check` → changedOnly: true (the default)// `check --no-changedOnly` → changedOnly: false- The negation is a derived CLI name, not an input field.
no-changedOnlyis not a key ofinput, so it is not selectable as a positional —positionals: ["no-changedOnly"]remains a spec error. An input field that would shadow a generated negation (a booleanloudbeside a field literally namedno-loud) is a spec error too, rather than a flag that quietly means only one of the two things. - It takes no value and consumes no token.
--no-loud=falseis rejected, andmyverb --no-loud alicereadsaliceas a positional, not as the flag's value. - Booleans stay scalar.
--loud --no-loudis last-wins, the same rule every other scalar flag follows. - Both defaults get one.
--no-is generated for every boolean, not only thedefault(true)ones — which spelling an author needs follows from the default, and a default is a value the verb may change. Generating it conditionally would mean flipping a default silently deletes a flag from every script using it.
When a verb's behaviour is a single mutually-exclusive choice, declare it as one
z.enum([...]) input, not as a set of booleans. It projects to one flag that
takes one of the members:
input: z.object({scope: z.enum(["changed","all"]).default("changed")}),// `sync` → scope: "changed" (the default)// `sync --scope all` → scope: "all"// `sync --scope=all` → scope: "all"- The members are the help text.
--helprenders--scope <changed|all>rather than--scope <string>: an enum's members are its content, so the JSON Schema type is the one placeholder that says nothing. Defaults and the(required)marker are unchanged —--scope <changed|all> (default: "changed"). - Validation is the schema's, not a second copy. A non-member is rejected by
input.parsewith Zod's own message, so the CLI never carries its own list of what is legal. - A bare
--scopeis a missing value, and says so. Unlike a boolean, an enum has no valueless spelling, so--scopeon its own is an arity error naming the members — not the wrong-value error that a bare flag's implicittruewould otherwise draw out of the schema. - Repeated enums work like any other array.
z.array(z.enum([...]))accepts both--tags x --tags yand--tags x,y, and renders as--tags <x|y,...>.
Preferring one enum over several booleans also keeps a one-axis choice from
becoming a set of flags that can contradict each other: --changed --all is not
representable when the axis is one field.
- One spec, four surfaces. The CLI, MCP server, Anthropic tool schema, and
OpenAPI operation are pure projections of a single
VerbSpec, so help text, arg parsing, validation, and tool schemas can't drift. - Zod-canonical. Runtime validation and static types from one definition;
z.toJSONSchemais the interchange IR. No codegen, no FFI, no build step to author a verb. - Self-contained. The only production dependency is the
zodpeer dep. An extractability test enforces outward-only imports and no ambient authority (no shelling out, noprocess.env).
MIT © Bounded Systems