npm.io
11.5.0 • Published yesterday

clibuilder

Licence
MIT
Version
11.5.0
Deps
0
Vulns
0
Weekly
323
Stars
10

CLI Builder

NPM version NPM downloads

GitHub Release Codecov Codacy Badge

Documentation

Visual Studio Code Wallaby.js

A highly customizable command line application builder.

Documentation

What's new in v8

Key highlights:

  • Support standalone CLI
    • name and version are now required and not read from package.json.
  • Plugins are loaded through config
    • This drastically improve startup time, as it does not scan node_modules anymore.
    • Also better support other package manager such as yarn PnP and pnpm.
  • keywords are now used for plugin lookup.
  • Distribute ESM along with CJS.

Feature Highlights

  • support default commands and sub-commands my-cli cmd1 cmd2 cmd3
  • configuration file support
  • plugin support: write commands in separate packages and reuse by multiple CLI
  • type inference and validation for config, arguments, and options
    using zod (exported as z)

Install

# npm
npm install clibuilder

# yarn
yarn add clibuilder

# pnpm
pnpm install clibuilder

#rush
rush add -p clibuilder

Usage

You can use clibuilder to create your command line application in many ways. The most basic way looks like this:

// Define your app
const app = cli({ name: 'app', version: '1.0.0' })
  .default({ run() { /* ...snip... */ }})

// Use your app
app.parse(process.argv)
  .catch(e => /* handle error */process.exit(e?.code || 1))

You can add additional named commands and sub-commands:

cli({ ... })
  .command({ name: 'hello', run() { this.ui.info('hello world') }})
  .command({
    name: 'repo',
    commands:[
      command({ name: 'create', run() { /* ..snip.. */ }})
    ]
  })

Command can have alias:

cli({ ... })
  .command({
    name: 'search-packages',
    alias: ['sp'],
    /* ..snip.. */
  })

// call as: `my-cli sp`

You can specify arguments:

cli({ ... }).default({
  arguments: [
    // type defaults to string
    { name: 'name', description: 'your name' }
  ],
  run(args) { this.ui.info(`hello, ${args.name}`) }
})

cli({ ... }).command({
  name: 'sum',
  arguments: [
    // using `zod` to specify number[]
    { name: 'values', description: 'values to add', type: z.array(z.number()) }
  ],
  run(args) {
    // inferred as number[]
    return args.values.reduce((p, v) => p + v, 0)
  }
})

Of course, you can also specify options:

cli({ ... }).default({
  options: {
    // type defaults to boolean
    'no-progress': { description: 'disable progress bar' },
    run(args) {
      if (args['no-progress']) this.ui.info('disable progress bar')
    }
  }
})

and you can add option alias too:

cli({ ... }).command({
  options: {
    project: {
      alias: ['p']
    }
  }
})

You can use z to mark argument and/or options as optional

cli({... }).default({
  arguments: [{ name: 'a', description: '', type: z.optional(z.string()) }],
  options: {
    y: { type: z.optional(z.number()) }
  }
})
Option types with t

t declares an argument or option type without zod. Each descriptor converts the raw value, carries its constraints as data that help and usage errors show, and gives run() the exact type. It comes from args-minus, which converts and checks every declaration made with it.

import { t } from 'clibuilder'

cli({ ... }).command({
  name: 'serve',
  arguments: [{ name: 'root', description: 'root folder', type: t.string() }],
  options: {
    port: { description: 'port', type: t.int({ min: 1, max: 65535 }), default: 8080, alias: ['p'] },
    format: { description: 'format', type: t.enum(['toon', 'text', 'json']).optional() },
    tag: { description: 'tags', type: t.array(t.string()).optional() },
    timeout: { description: 'e.g. 1m30s', type: t.custom(parseDuration).optional() },
    email: { description: 'email', type: t.string().optional().check(z.string().email()) }
  },
  run(args) {
    // args.port: number, args.format: 'toon' | 'text' | 'json' | undefined, args.tag: string[] | undefined
  }
})

Help shows each constraint, e.g. [-p|--port <int 1..65535>] and [--format <toon|text|json>].

A command can mix z and t: each argument and option is parsed by its own type, and declarations with z behave exactly as before. Declarations with t follow args-minus:

  • an option that is neither .optional() nor given a default must be passed, and is reported as missing otherwise;
  • t.boolean() is a flag: it takes no following value, but --dry=false is accepted;
  • t.array() takes one value per occurrence: --tag a --tag b;
  • .check() and t.custom() may be async; they run after --help and --version are answered;
  • a value that fails conversion, a constraint or a check is an invalid-value usage error with the option's constraint (and allowed values for t.enum). A converter that throws a plain Error reports its message; any other throw is a bug and propagates.

The z form is removed in v12.

If you invoke a command expecting a config, the config will be loaded. Each command defines their own config.

cli({ ... })
.default({
  config: z.object({ presets: z.string() }),
  run() {
    this.ui.info(`presets: ${this.config.presets}`)
  }
})

Config

Config file can be written in JSON, YAML, cjs, or mjs. Common filename are supported:

  • .{name}.<cjs|mjs|js|json|yaml|yml>
  • .{name}rc.<cjs|mjs|js|json|yaml|yml>
  • {name}.<cjs|mjs|js|json|yaml|yml>
  • {name}rc.<cjs|mjs|js|json|yaml|yml>

You can override the config name too:

cli({ config: 'alt-config.json' })

Plugins

One of the key features of clibuilder is supporting plugins. Plugins are defined inside the config:

{
  "plugins": ["my-cli-plugin"]
}

Plugin support is moving to @clibuilder/plugins, which a CLI adds with .use(plugins()). Both ways work in v11; v12 keeps only @clibuilder/plugins.

Defining Plugins

clibuilder allows you to build plugins to add commands to your application. i.e. You can build your application in a distributed fashion.

To create a plugin:

  • export a activate(ctx: PluginActivationContext) function
  • add the keywords in your package.json to make it searchable
import { command, PluginActivationContext } from 'clibuilder'

// in plugin package
const sing = command({ ... })
const dance = command({ ... })

export function activate({ addCommand }: PluginCli.ActivationContext) {
  addCommand({
    name: 'miku',
    commands: [sing, dance]
  })
}

// in plugin's package.json
{
  "keywords": ['your-app-plugin', 'vocaloid']
}

The CLI can search for plugins using the keywords values.

Testing

testCommand() can be used to test your command:

import { command, testCommand } from 'clibuilder'

test('some test', async () => {
  const { result, messages } = await testCommand(command({
    name: 'cmd-a',
    run() {
      this.ui.info('miku')
      return 'x'
    }
  }), 'cmd-a')
  expect(result).toBe('x')
  expect(messages).toBe('miku')
})

Startup time

Node can cache the compiled bytecode of every module your CLI loads, so the second and later runs skip compiling them again. clibuilder exposes it as an opt-in helper:

#!/usr/bin/env node
import { enableCompileCache } from 'clibuilder/compile-cache'

enableCompileCache()

const { cli } = await import('clibuilder')

cli({ name: 'app', version: '1.0.0' })
  .default({ run() { /* ...snip... */ } })
  .parse(process.argv)

or in CJS:

#!/usr/bin/env node
require('clibuilder/compile-cache').enableCompileCache()

const { cli } = require('clibuilder')

Measured on the test-apps fixtures (node 24, median of 25 warm runs):

build without with
ESM 86ms 81ms
CJS 43ms 37ms

Two rules make it work, and both are easy to get wrong:

  • Call it first. The cache applies to modules compiled after the call, so it must run before your CLI and its dependencies are loaded. Calling it later — from your command's run(), or from inside cli() — caches nothing. That is why clibuilder does not turn it on for you: by the time cli() runs, everything worth caching is already compiled.
  • In ESM, use await import. A static import compiles the entire module graph before any of it runs, so a statically imported cli is already compiled by the time enableCompileCache() executes. clibuilder/compile-cache is a separate entry point that pulls in nothing but node:module, so importing it statically costs nothing.

The helper never throws. On runtimes without the API (node < 22.1, and currently bun and deno) it does nothing and reports why:

const { enabled, directory, message } = enableCompileCache()

By default node picks the cache location. Pass a directory to choose your own:

enableCompileCache(path.join(os.homedir(), '.cache/my-cli'))

If the user sets the NODE_COMPILE_CACHE environment variable, their directory wins and the argument is ignored. They can also use that variable to switch the cache on for a CLI that does not call the helper at all.

shebang

To make your CLI easily executable, you can add shebang to your script:

#!/usr/bin/env node

// your code

Keywords