Configuration
A CLI opts into config by adding the config() extension from the clibuilder/config subpath with
.use(). Config loads for the commands only. It does not load
plugins.
import { cli } from 'clibuilder'import { config } from 'clibuilder/config'
cli({ name: 'app', version: '1.0.0' }).use(config())config() derives the config name from the CLI name (app). Pass name to override it:
cli({ name: 'app', version: '1.0.0' }).use(config({ name: 'alt-config' }))A CLI without .use(config()) contains no config loader, no find-up and no parsers. .use(config())
gives the CLI the global --show-config option, the validation of
each command’s config schema before run(), and this.config. The config is resolved on the first
parse(), not when cli() is called.
A command that declares config needs .use(config()) on its CLI. Call .use(config()) before
.command() and .default(): on a CLI without it, a command with config is a type error, and at
run time it fails with:
Error: command 'x' declares config, but the cli does not load config. Add .use(config()) from 'clibuilder/config' to the cli.Where the config is found
Section titled “Where the config is found”Given a config name of app, these filenames are searched, in order, walking up from the current
working directory:
app,app.cjs,app.mjs,app.js,app.json,app.jsonc,app.yml,app.yamlapprc.cjs,apprc.mjs,apprc.js,apprc.json,apprc.jsonc,apprc.yml,apprc.yaml,apprc
Each also matches with a leading dot — .apprc.json, .app.yaml, and so on. (If your config name
already starts with ., the dotted variants are skipped.)
If none of those exist, the nearest package.json is checked for a top-level key matching the config
name:
{ "name": "my-project", "app": { "presets": "recommended" }}If nothing is found at all, the CLI warns and this.config is undefined.
Declaring what a command needs
Section titled “Declaring what a command needs”Config is validated per command, against the schema that command declares. A command with no
config schema doesn’t trigger a load and doesn’t get one.
config takes any Standard Schema: zod 3.24 or later, zod 4, valibot
or arktype. Install the library as your own dependency and import it from there. clibuilder does
not install one for you.
import { cli } from 'clibuilder'import { config } from 'clibuilder/config'import { z } from 'zod'
cli({ name: 'app', version: '1.0.0' }) .use(config()) .default({ config: z.object({ presets: z.string() }), run() { // this.config: { presets: string } this.ui.info(`presets: ${this.config.presets}`) } }) .parse(process.argv)this.config is the schema’s output, so downstream code sees the real shape. Defaults are filled in,
transforms are applied, and a zod z.object() strips keys it does not declare. A schema whose
validate returns a promise is awaited.
The same works with valibot:
import { cli } from 'clibuilder'import { config } from 'clibuilder/config'import * as v from 'valibot'
cli({ name: 'app', version: '1.0.0' }) .use(config()) .default({ config: v.object({ presets: v.string(), port: v.optional(v.number(), 3000) }), run() { // this.config: { presets: string; port: number } this.ui.info(`presets: ${this.config.presets} on ${this.config.port}`) } }) .parse(process.argv)Different commands can ask for different slices of the same file — each declares only what it uses, and each validates independently.
cli({ name: 'app', version: '1.0.0' }) .use(config()) .command({ name: 'build', description: 'build the project', config: z.object({ outDir: z.string() }), run() { this.ui.info(`building into ${this.config.outDir}`) } }) .command({ name: 'serve', description: 'serve the project', config: z.object({ port: z.number() }), run() { this.ui.info(`serving on ${this.config.port}`) } })When validation fails
Section titled “When validation fails”The CLI prints each issue’s message under the dotted path of the value that failed, such as
server.port, and then the command’s help message. An issue about the config as a whole is printed
under (config). The command does not run.
config fails validation: presets: RequiredMark fields you don’t want to be mandatory as optional, or give them a default, in the schema.
In help
Section titled “In help”The Config: section of a command’s help is generated only from the schema’s JSON Schema, which a
Standard Schema exposes as ~standard.jsonSchema. Zod 4, valibot (through its converter) and arktype
provide it:
Config:{ presets: string, port?: number }Help shows the shape a config file is written in, so a field with a default is optional. A zod 3
schema has no JSON Schema, so its Config: section is left out.
File formats
Section titled “File formats”| Extension | Format |
|---|---|
.json, .jsonc |
JSON with comments and trailing commas |
.yml, .yaml |
YAML. Needs the optional peer js-yaml |
.cjs |
CommonJS module — its export is the config |
.mjs, .js |
ES module — its default export is the config |
| none | inferred from the content — JSONC, then YAML, then module |
YAML needs js-yaml, an optional peer dependency. Install it with npm install js-yaml in a CLI
whose users write a .yaml or .yml config. Without it, reading a YAML config fails with a
CliError whose code is missing-peer-dependency.
The format comes from the extension, so a .json file that is not valid JSON is reported as a parse
error against that file rather than being silently retried as YAML.
Comments and trailing commas
Section titled “Comments and trailing commas”.json and .jsonc are both parsed as JSONC, so comments and trailing commas are allowed in
either. Extension-less files such as .apprc get the same treatment when their content looks like
JSON.
{ // the preset to build with "presets": "recommended", "plugins": ["my-cli-plugin"],}Comment-like sequences inside strings stay part of the string — "http://example.com" is not
truncated at the //.
The executable formats are the escape hatch for config that has to be computed:
export default { presets: process.env.CI ? 'ci' : 'recommended'}Inspecting the resolved config
Section titled “Inspecting the resolved config”A CLI with .use(config()) gets a --show-config option, which prints the config it resolved and the
file it came from. It is an answer on stdout and follows --format, toon by default:
$ app --show-configsource: filepath: /home/me/project/app.jsoncconfig: presets: recommended--format json gives the same fields as JSON, and --format text gives the lines as a person reads
them:
$ app --show-config --format textconfig: /home/me/project/app.jsonc{ "presets": "recommended"}When the config came from package.json, source is package.json and property names the key
that was read. Under --format text the property is named after the path:
config: /home/me/project/package.json (property "app")And when nothing matched, source is none, config is null, and help says where a config
would be read from:
$ app --show-configsource: noneconfig: nullhelp[1]: "No config found in this directory or a parent. Create `apprc.json` or another config file (`--show-config --verbose` lists the names), or add a \"app\" key to package.json"Under --format text, the first line is config: not found, followed by the same help.
--verbose lists every filename that was searched for, as a diagnostic on stderr.
With output: 'console', --show-config writes the text lines through the CLI’s ui, on stdout,
whatever the --format, as in 11.5.0.
Reading the config yourself
Section titled “Reading the config yourself”clibuilder/config also exports the functions the CLI uses to find and read config, such as lookupConfig() and resolveConfig(). See clibuilder/config.
The plugins key
Section titled “The plugins key”A CLI that calls .use(plugins()) reads a plugins key that lists the packages whose commands get
loaded. See Plugins.
{ "plugins": ["my-cli-plugin"], "presets": "recommended"}You do not need to declare plugins in a command’s schema — but if your schema is a strict object
that rejects unknown keys, remember it is there. Plugins are loaded before a command’s schema runs, so
a schema that strips unknown keys does not stop them loading.