@cli-schema/commander
v0.2.0
Published
Generates a CLI Schema document from a live Commander command tree (https://github.com/cli-schema/cli-schema)
Readme
@cli-schema/commander
Generates a CLI Schema document from a live
Commander command tree — dynamically. There's no
registration list to keep in sync: point it at your program and it walks the real commands,
options, and positionals you already built.
Part of the cli-schema-js monorepo.
Quickstart
npm install @cli-schema/commander commanderimport { Command } from 'commander'
import { buildCliSchema } from '@cli-schema/commander'
const program = new Command('mytool')
program.command('search').description('Search an index').argument('<query>')
const schema = buildCliSchema(program, { name: 'mytool', version: '1.0.0' })
console.log(JSON.stringify(schema, null, 2))That's a valid CLI Schema document already — name, version, and commands are discovered
automatically. Everything below is about enriching it.
Purpose
Commander already knows your command tree — names, descriptions, options, positionals, aliases. What it doesn't know is the extra layer CLI Schema adds on top for agent/tooling consumption: side-effect intent (is this destructive? does it need confirmation?), richer parameter typing than Commander's own model (enums, array element types, range constraints), and structured metadata like tags or deprecation notices.
@cli-schema/commander bridges that gap. It reads everything Commander already exposes
dynamically, and gives you two ways — a fluent one and a functional one — to attach the parts
Commander has no concept of.
Features
- Dynamic discovery, not registration —
buildCliSchema(root, options)walksroot.commandsrecursively. Options and positionals come straight from Commander's owncmd.options/cmd.registeredArguments. There's nothing to forget to register. - Two equivalent ways to attach
.intent()/.input()/.cliMeta():- Fluent, via an opt-in
Command.prototypeaugmentation:import '@cli-schema/commander/augment' - Functional, with no prototype patching at all:
import { attachIntent, attachInput, attachCliMeta } from '@cli-schema/commander'
buildCliSchemareads either form identically — pick whichever fits your codebase, or mix them. - Fluent, via an opt-in
- Zod input schemas become extra parameters automatically. Attach a schema with
.input()/attachInput();buildCliSchemaderives parameters for it via@cli-schema/zod(types, enums, array element types, validation constraints). When a schema field's flag name already matches a real Commander option, the two are reconciled, not one discarding the other — see Reconciling Commander and schema detail. - Flag-role heuristics, overridable.
--dry-runis classifiedrole: "dryRun"and--yes/--force/--confirm/--no-input/--non-interactiveasrole: "confirmationSkip"by default (spec §6.1) — passclassifyRoleto change the rule. - Automatic option hoisting. Flags common to every command in a namespace are promoted to
that namespace's
options; flags common across every namespace and root command are promoted further to the document'sglobalOptions. You don't repeat--jsonon every single command in the emitted document just because every command in your CLI happens to support it. - Reconciling Commander and schema detail is a hook, not a fixed rule.
mergeParameter(default: schema wins on richness, Commander wins onrole/shortName/hidden),enrichSchemaArg, andpostProcessInputParameterlet you tune or fully override how the two sources combine for a given flag — see Reconciling Commander and schema detail. - Hidden commands respected. Commands registered with Commander's own
{ hidden: true }(via.command()/.addCommand()) are excluded from the document. registerZodOptions— an opt-in convenience for quick CLIs: derives and registers basic Commander options directly from a Zod schema, so you don't hand-write.option()calls at all.- Spec-conformant, lenient output. Empty
commands/namespaces/globalOptionsare omitted rather than emitted as empty arrays, matching the specification's lenient root object.
Usage
Attaching intent (fluent)
import '@cli-schema/commander/augment'
import { Command } from 'commander'
const del = new Command('delete')
.description('Delete a document')
.argument('<id>')
.intent({ destructive: true, idempotent: true, scope: 'global', requiresConfirmation: true })Attaching intent (functional — no prototype patching)
import { Command } from 'commander'
import { attachIntent } from '@cli-schema/commander'
const del = new Command('delete').description('Delete a document').argument('<id>')
attachIntent(del, { destructive: true, idempotent: true, scope: 'global', requiresConfirmation: true })Deriving parameters from a Zod input schema
import { z } from 'zod'
import { Command } from 'commander'
import '@cli-schema/commander/augment'
import { buildCliSchema } from '@cli-schema/commander'
const search = new Command('search')
.description('Search an index')
.input(z.object({
q: z.string().describe('Query string'),
size: z.number().default(10).describe('Number of results'),
}))
const program = new Command('mytool').addCommand(search)
const schema = buildCliSchema(program, { name: 'mytool', version: '1.0.0' })
// schema.commands[0].parameters includes `q` and `size`, with types/descriptions/defaults
// derived straight from the Zod schema — even though neither was ever registered as a real
// Commander `.option()`.If you do want real, working --q/--size flags, register them yourself with .option(), or
use registerZodOptions for the simple case:
import { registerZodOptions } from '@cli-schema/commander'
registerZodOptions(search, z.object({ q: z.string(), size: z.number().default(10) }))
// search now has real --q <value> and --size <value> options.Reconciling Commander and schema detail
If a schema field's flag name matches a real Commander option, buildCliSchema doesn't have to
choose one source and throw the other away — it merges them, by default preferring the
schema's richer detail (type, required, defaultValue, enumValues, elementType,
validations, repeatable, summary) while keeping role, shortName, and hidden from the
real Commander Option, since a Zod schema has no concept of any of those.
Why this needs to be a decision at all, rather than always trusting Commander: some CLIs register
every option on Commander as merely optional, with no default, and enforce required/defaults/
enum membership/ranges purely through the Zod schema at parse time — often because a value can
arrive through more than one channel (a flag, a JSON file, stdin), and Commander can only ever
validate the flag form. In that shape, Commander's own Option is an inaccurate, impoverished
view of the real contract; the schema is the source of truth for everything except the raw
flag/alias/hidden surface. Other CLIs pass accurate required/defaults to Commander directly and
only reach for a schema to layer JSON-Schema detail (enums, ranges) on top — there, the existing
option's required/default might be just as trustworthy as the schema's.
Because which side is authoritative depends on how your CLI is built, this is exposed as a hook —
mergeParameter — rather than a fixed rule or a merge/replace toggle:
buildCliSchema(program, {
name: 'mytool',
version: '1.0.0',
// Trust Commander over the schema when both describe the same flag:
mergeParameter: (commanderParam, schemaParam) => commanderParam ?? schemaParam,
})The default behaves as if you'd written
mergeParameter: (commanderParam, schemaParam) => commanderParam == null ? schemaParam : { ...schemaParam, role: commanderParam.role, shortName: commanderParam.shortName, hidden: commanderParam.hidden }.
Two more hooks round this out:
enrichSchemaArg?: (field, base) => Record<string, unknown>— threaded to@cli-schema/zod's ownextractSchemaArgsenrichoption, for attaching your own metadata (e.g. HTTP-transport routing) to a schema arg before it becomes a parameter.postProcessInputParameter?: (param, arg) => Parameter— runs aftermergeParameter, so you can add fields this package has no business knowing about — e.g. aseparatorthat only applies to array-accepting fields your framework routes into a JSON body:buildCliSchema(program, { name: 'mytool', version: '1.0.0', enrichSchemaArg: (field) => ({ foundIn: field.meta()?.found_in }), postProcessInputParameter: (param, arg) => arg.acceptsArrayForm && (arg as { foundIn?: string }).foundIn === 'body' ? { ...param, separator: ',' } : param, })
Extra metadata (notes, tags, output, ...)
Anything the spec's Command Object
defines that Commander has no concept of — notes, usage, examples, tags, deprecated,
output, streaming, longRunning — goes through .cliMeta() / attachCliMeta():
del.cliMeta({
tags: ['dangerous'],
deprecated: { message: 'Use `purge` instead', since: '2.0.0' },
})Building the full document
import { buildCliSchema } from '@cli-schema/commander'
const schema = buildCliSchema(program, {
name: 'mytool',
version: '1.4.2',
description: 'An example CLI',
environment: {
variables: [{ name: 'MYTOOL_TOKEN', required: false, description: 'API token' }],
configFiles: [{ path: '~/.mytoolrc.yml', required: false }],
},
reservedMetaCommands: ['__schema'],
// Namespaces/commands NOT in this set default to intent.requiresAuth: true, unless a command
// explicitly sets its own requiresAuth via .intent()/attachIntent().
noContextNames: new Set(['config', 'help']),
})Wiring up spec discovery (__schema)
The spec recommends implementations respond to a single __schema argument by printing the
document to stdout (spec §4.1). A minimal wiring:
program
.command('__schema', { hidden: true })
.description('Emit the CLI structure as JSON')
.action(() => {
console.log(JSON.stringify(buildCliSchema(program, { name: 'mytool', version: '1.4.2' }), null, 2))
})Registering it as hidden keeps it out of your own --help output while still being
discoverable by anything that knows to run mytool __schema.
Custom role classification
buildCliSchema(program, {
name: 'mytool',
version: '1.0.0',
classifyRole: (longName) => (longName === 'nuke' ? 'confirmationSkip' : 'flag'),
})When the tree you're walking isn't the program with your real global options
If your CLI lazy-loads its command tree for startup performance — only building the invoked
subtree, leaving everything else as empty stub commands — you can't hand buildCliSchema the
real program directly; it would see mostly stubs. The usual fix is to assemble a synthetic root
purely for schema generation, eager-loading every command onto it. That synthetic root has no
options of its own, even though your real program's global flags (--json, etc.) should still
show up in globalOptions. Point globalOptionsSource at wherever those really live instead of
copying them onto the synthetic root by hand:
const schemaRoot = new Command('mytool')
// ... eager-load every namespace onto schemaRoot ...
buildCliSchema(schemaRoot, {
name: 'mytool',
version: '1.0.0',
globalOptionsSource: program.options, // the real, lazily-populated program
})Defaults to root.options — most callers (a CLI that isn't lazy-loading) never need this.
Related packages
@cli-schema/spec— theCliSchema/Intent/Parametertypes this package produces, plus a validator to check the result@cli-schema/zod— the Zod-schema introspection this package uses for.input()-derived parameters
License
MIT
