@docspack/cmdspec
v0.2.0
Published
cmdspec: a machine-readable description of a command-line interface. Validate it, convert it to and from Usage and OpenCLI, and render a per-command digest for an agent.
Maintainers
Readme
@docspack/cmdspec
cmdspec: a machine-readable description of a command-line interface.
OpenAPI says what an HTTP API takes and returns. Nothing has said the same about a CLI with that standing: OpenCLI is a 0.1 draft with no value types, and Usage is rich but has no schema for its own documents. cmdspec is a format that contains both — anything either says converts into it without loss — and adds what neither says: whether a command is safe to run, what its exit statuses mean, what it prints, and which options apply to which operand.
- The specification:
SPEC.md(CC BY 4.0) - The schema:
schema/v0.1.json, JSON Schema 2020-12 - Examples: docspack, git, kubectl and ffmpeg, in
examples/ - Why it exists, and what was measured:
docs/design/cmdspec.md
cmdspec: "0.1"
info: { name: greet, version: 1.0.0 }
arguments:
- { name: who, required: true }
options:
- { name: --shout, aliases: [-s], summary: Print in capitals }
effects: []
examples:
- run: greet world --shoutThe CLI
npx @docspack/cmdspec validate cmdspec.yaml
npx @docspack/cmdspec convert mise.usage.kdl --to cmdspec --program-version 2026.10.0
npx @docspack/cmdspec convert cmdspec.yaml --to opencli -o opencli.json
npx @docspack/cmdspec digest cmdspec.yaml remote add
npx @docspack/cmdspec map cmdspec.yamlvalidate reports every problem, not the first, and names the command and option each one is in.
The CLI is itself defined by a cmdspec document — cmdspec.yaml: its parser, its
--help and cmdspec --cmdspec all come from that one file.
The digest — what an agent reads
One command, everything needed to run it, in the shape of a help page:
docspack ask <question>... [options]
Answer from the local index — the command to give an agent
<question>... words are joined with spaces
-p, --package <s> only packages whose name contains this text
--limit <n> maximum chunks to return (integer; at least 1; default 3)
--max-tokens <n> token ceiling for the result set (integer; at least 1; default 3000)
--all search the whole store, not just this project
also accepts: --store <path>, --cwd <dir>, --json, -q, --quiet, -h, --help
effects: read; idempotent
stdout: text/markdown, the answer; application/json, the chunks and their scores with --json
exit: 0 success; 1 the command failed; 2 the command was used wrongly; 3 nothing matched, and a docs package is installed but not indexed; 4 nothing matched, and everything installed is already indexed
$ docspack ask "how do I verify a webhook signature"
$ docspack ask "webhook signature" --package stripe --limit 5 # Look in one package only, and return moreIt is not a notation: every annotation is a word, because the help page is the format a model has seen most, and the design doc measured that words cost 3.2% more than markers it would have to be taught. What it adds to a help page is what help pages leave out — types, defaults, effects, outputs and exit statuses. The largest digest across the four examples is 544 tokens.
Converting
| From → to | Loses |
| --- | --- |
| Usage → cmdspec | nothing: unmapped properties and nodes are kept in x-usage and written back |
| OpenCLI → cmdspec | nothing: unmapped fields are kept as x-opencli-* |
| cmdspec → OpenCLI | nothing: what OpenCLI cannot say travels in its metadata |
| cmdspec → Usage | what Usage cannot say — listed, never dropped quietly |
Tested on real documents rather than on promises: mise's own Usage spec (290 commands) survives Usage → cmdspec → Usage → cmdspec unchanged and converts to a valid document, and dotnet's published OpenCLI document (813 KB) comes back from OpenCLI → cmdspec → OpenCLI as the same JSON.
As a library
import {
commandDigest,
fromOpenCli,
mergeOverlay,
parseArgsConfig,
resolveDocument,
toOpenCli,
} from "@docspack/cmdspec";
import { validate, validateOverlay } from "@docspack/cmdspec/validate";
import { readFile } from "@docspack/cmdspec/read";
import { fromUsage, toUsage } from "@docspack/cmdspec/usage";
import { fromCitty, fromCommander, fromYargs } from "@docspack/cmdspec/emit";| Entry | Contains | Dependency |
| --- | --- | --- |
| @docspack/cmdspec | types, resolveDocument, the digest and map, OpenCLI conversion, parseArgsConfig, the rules, mergeOverlay | none |
| @docspack/cmdspec/validate | validate — the schema, then the rules; validateOverlay | ajv |
| @docspack/cmdspec/read | readFile, readText — JSON or YAML, validated | yaml |
| @docspack/cmdspec/usage | fromUsage, toUsage | @bgotink/kdl |
| @docspack/cmdspec/emit | fromCommander, fromCitty, fromYargs | none |
| @docspack/cmdspec/builder | mountCommandBuilders — the command builder, in a browser | none |
parseArgsConfig(command) gives node:util's parseArgs its options from a command's document,
so a Node CLI can parse its command line from the same file it is documented by.
A command line from a reader's choices
commandLine(builderTarget(command), values) writes the command line a set of values makes —
attaching values the way the document's syntax says, quoting for a POSIX shell, putting --
before arguments that come after it — and lists the required inputs still missing.
@docspack/sheaf-react's CliBuilder renders that as a form, and @docspack/cmdspec/builder runs it in the
browser as the reader types.
From a commander, citty or yargs program
A program already built with one of these frameworks has its command line in memory. The emitters read it and write the document, so describing it starts from what it actually parses:
import { fromCommander } from "@docspack/cmdspec/emit";
import { program } from "./cli.js"; // the commander program, not yet parsed
const document = fromCommander(program);fromCitty(command) resolves every lazy subcommand, so it is async. fromYargs(build) takes the
function that builds the program, because yargs builds a command's options only while running it,
on the program itself; it is called once per command, and the version comes from
fromYargs(build, { version }). No framework is imported: each emitter reads the few fields it
needs, and the entry has no dependencies.
What a framework does not know, the document does not say: effects, exit statuses, outputs and
examples. Keep them in an overlay, so the next generation does not lose them;
docspack build warns for each one still missing.
Keeping what a framework cannot know
An overlay is a file beside the program — schema/overlay-v0.1.json
— merged into every document generated from it:
cmdspecOverlay: "0.1"
exits:
"0": { meaning: success }
commands:
serve:
effects: [network]
options:
--port: { value: { minimum: 1024 } }
db drop:
effects: [destructive]import { mergeOverlay } from "@docspack/cmdspec";
const result = mergeOverlay(fromCommander(program), overlay);
if (!result.merged) throw new Error(result.problems.map((p) => `${p.at}: ${p.message}`).join("\n"));Commands are keyed by their path below the program, options by spelling and arguments by name.
Every entry must name something the program has, so renaming db drop makes the overlay a
problem rather than a patch that silently stops applying. The overlay fills what the document
leaves out; where the program states a value, the overlay must agree or list the field in
override. validateOverlay checks an overlay against its schema, and mergeOverlay reports
every problem, not the first. Why it works this way is
docs/design/cmdspec-adapters.md.
Finding a program's document
A package names its document in package.json — "cmdspec": "./dist/cmdspec.json", as this one
does — and a program may print it when run with --cmdspec. Both are in SPEC §13.
Licence
The code, the schema and the examples are MIT. The specification text, SPEC.md, is
CC BY 4.0. The fixtures are MIT-licensed
documents from mise and OpenCLI; see tests/fixtures/README.md.
