npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 commander
import { 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 registrationbuildCliSchema(root, options) walks root.commands recursively. Options and positionals come straight from Commander's own cmd.options / cmd.registeredArguments. There's nothing to forget to register.
  • Two equivalent ways to attach .intent() / .input() / .cliMeta():
    • Fluent, via an opt-in Command.prototype augmentation:
      import '@cli-schema/commander/augment'
    • Functional, with no prototype patching at all:
      import { attachIntent, attachInput, attachCliMeta } from '@cli-schema/commander'
    buildCliSchema reads either form identically — pick whichever fits your codebase, or mix them.
  • Zod input schemas become extra parameters automatically. Attach a schema with .input() / attachInput(); buildCliSchema derives 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-run is classified role: "dryRun" and --yes/--force/--confirm/--no-input/--non-interactive as role: "confirmationSkip" by default (spec §6.1) — pass classifyRole to 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's globalOptions. You don't repeat --json on 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 on role/shortName/hidden), enrichSchemaArg, and postProcessInputParameter let 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/globalOptions are 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 own extractSchemaArgs enrich option, for attaching your own metadata (e.g. HTTP-transport routing) to a schema arg before it becomes a parameter.
  • postProcessInputParameter?: (param, arg) => Parameter — runs after mergeParameter, so you can add fields this package has no business knowing about — e.g. a separator that 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 — the CliSchema/Intent/Parameter types 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