Skip to content

Repository files navigation

@bounded-systems/verbspec

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)

Install

npm install @bounded-systems/verbspec zod

zod is a peer dependency (^3.25 || ^4).

Usage

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 /greet

parseArgs 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.

positionals vs. input fields

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 flag

Two consequences, both enforced rather than silently absorbed:

  • A field not listed in positionals is flag-only. Declaring slug: z.string().optional() with positionals: [] means myverb somevalue is 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 positionals that no input field declares is a spec error. It would bind a value that input.parse then strips as an unknown key, so parseArgs rejects 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.

Boolean flags and --no-

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-changedOnly is not a key of input, 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 boolean loud beside a field literally named no-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=false is rejected, and myverb --no-loud alice reads alice as a positional, not as the flag's value.
  • Booleans stay scalar.--loud --no-loud is last-wins, the same rule every other scalar flag follows.
  • Both defaults get one.--no- is generated for every boolean, not only the default(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.

Enum flags — one flag for a one-axis choice

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.--help renders --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.parse with Zod's own message, so the CLI never carries its own list of what is legal.
  • A bare --scope is a missing value, and says so. Unlike a boolean, an enum has no valueless spelling, so --scope on its own is an arity error naming the members — not the wrong-value error that a bare flag's implicit true would otherwise draw out of the schema.
  • Repeated enums work like any other array.z.array(z.enum([...])) accepts both --tags x --tags y and --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.

Design

  • 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.toJSONSchema is the interchange IR. No codegen, no FFI, no build step to author a verb.
  • Self-contained. The only production dependency is the zod peer dep. An extractability test enforces outward-only imports and no ambient authority (no shelling out, no process.env).

License

MIT © Bounded Systems

About

Spec-driven CLI core: author a verb once as a typed VerbSpec, project it to CLI, MCP, OpenAPI, and Anthropic tool surfaces

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages