Plugins
A plugin is an ordinary npm package that exports an activate() function and adds commands through
it. This is how a CLI can be built in a distributed fashion — the core stays small, and features ship
as packages that plug into it.
Two mechanisms are involved, and it helps to keep them apart:
- Loading — which plugins actually run. Driven by the
pluginsarray in the CLI’s config file. - Discovery — which plugins exist, on disk or on npm, so a user can decide what to load. Driven
by the keywords passed to
plugins().
Both come from @clibuilder/plugins. clibuilder itself has no
plugin loading.
Enabling plugins in your CLI
Section titled “Enabling plugins in your CLI”Install @clibuilder/plugins and call .use(plugins()). Plugins are listed in the
config file, so a plugin-capable CLI declares config too.
Pass keywords — they are how plugins search and plugins list find candidate packages on npm
and on disk.
import { cli } from 'clibuilder'import { config } from 'clibuilder/config'import { plugins } from '@clibuilder/plugins'
cli({ name: 'my-cli', version: '1.0.0' }) .use(config()) .use(plugins({ keywords: ['my-cli-plugin'] })) .parse(process.argv)Add config() before plugins(). plugins() reads the plugins key from the config that config()
resolved, so without config() its config source sees no config.
A CLI that does not call plugins() has no plugin system. config() alone loads the config for the
commands and nothing else. If the config lists plugins and no extension reads it, the CLI warns
once and names @clibuilder/plugins.
Choosing a keywords namespace
Section titled “Choosing a keywords namespace”A keyword is a public contract: it is the string a plugin author has to put in their
package.json for your users to find their package. Pick one that is unlikely to collide with
anything else on npm, and then treat it as stable — changing it later orphans every plugin already
published against the old one.
<cli-name>-plugin is the convention this library’s own fixtures use, and it is a good default:
cli({ name: 'my-cli', version: '1.0.0' }) .use(config()) .use(plugins({ keywords: ['my-cli-plugin'] }))A few notes on the shape of the list:
- The CLI name alone is a weak keyword.
plugins({ keywords: ['my-cli'] })matches anything on npm that happens to carry your CLI’s name — including packages that have nothing to do with your plugin contract. The-pluginsuffix narrows it to packages that opted in deliberately. keywordsis an array, and a package matches if it carries any of them. That is the escape hatch for renaming: publish under the new keyword and keep the old one listed until the ecosystem catches up.- If you omit
keywords, the CLInameis used as the keyword. This is a convenience default, not a recommendation — a CLI that expects a real plugin ecosystem should declare its namespace explicitly.
// keywords is ['my-cli'] — same as writing plugins({ keywords: ['my-cli'] })cli({ name: 'my-cli', version: '1.0.0' }).use(config()).use(plugins())The default also applies when config() names a different file — the keyword still comes from name,
not from the config name:
// config file is `my-tool.json`, but the plugin keyword is still 'my-cli'cli({ name: 'my-cli', version: '1.0.0' }).use(config({ name: 'my-tool' })).use(plugins())Listing plugins in config
Section titled “Listing plugins in config”{ "plugins": ["my-cli-plugin", "@scope/another-plugin"]}Each entry is imported by name, resolved from the current working directory. Their commands are
added to the CLI before parse() resolves which command to run.
The trade-off is that installing a plugin is not enough to activate it — the user also adds it to
plugins. That is deliberate: a package that lands in node_modules as a transitive dependency
should not be able to inject commands into a CLI the user runs.
A plugin that fails to import, or that has no activate export, is skipped with a warning rather
than crashing the CLI. Run with --verbose to see which plugin was skipped and why:
$ my-cli --verbose --helpclibuilder loading plugin bad-pluginclibuilder not a valid plugin bad-pluginThe built-in plugins command
Section titled “The built-in plugins command”plugins() adds a plugins command. You do not register it, and it shows up in the help output alongside your own commands. Pass plugins({ command: false }) to leave it out:
$ my-cli plugins -h --format text
Usage: my-cli plugins <command>
Commands related to the plugins of the cli
Commands: list (ls), search
plugins <command> -h Get help for <command>Invoked bare, plugins shows the installed plugins, followed by the group’s own commands:
$ my-cli pluginsplugins[3]: my-cli-plugin,my-cli-plugin-git,@acme/my-cli-plugin-deploycommands[2]: list,searchThe two sub-commands answer two different questions.
plugins list — what is installed
Section titled “plugins list — what is installed”list walks the packages installed in the current working directory and reports the ones carrying
any of the CLI’s keywords. It reads the local dependency tree only; it never touches the network.
$ my-cli plugins listplugins[3]: my-cli-plugin,my-cli-plugin-git,@acme/my-cli-plugin-deployhelp[1]: Run `my-cli plugins search` to find more plugins on npmWith none installed:
$ my-cli plugins listempty: 0 installed plugins found with keywords: my-cli-pluginhelp[1]: Run `my-cli plugins search` to find plugins to installNote what list is not: it reports what is installed, not what is loaded. A package can appear
here and still be inert because it is missing from the config’s plugins array.
plugins search — what exists on npm
Section titled “plugins search — what exists on npm”search queries the npm registry for published packages carrying any of the CLI’s keywords. This is
how a user finds a plugin they have not installed yet.
$ my-cli plugins searchpackages[3]: my-cli-plugin,my-cli-plugin-git,@acme/my-cli-plugin-deployhelp[1]: Run `my-cli plugins list` to see which of them are installedsearch also takes --fields keywords, which reports which of the CLI’s keywords found each
package — worth asking when one broad keyword can pull in a package that is not a plugin at all:
$ my-cli plugins search --fields keywordspackages[2]: - name: my-cli-plugin keywords[1]: my-cli - name: shared-tool keywords[2]: my-cli,unionalhelp[1]: Run `my-cli plugins list` to see which of them are installedA list longer than 20 entries is cut to the first 20, with count: 20 of 47 total and a next step
that adds --full, which shows them all. Each next step names a command in the group, with the
CLI’s name, so it runs as written.
--format — who is reading
Section titled “--format — who is reading”Both sub-commands take --format <toon|text|json>. The default is toon, following the
Agent eXperience Interface: a CLI’s own plugin metadata is read by an
agent far more often than by a person, and toon is the cheaper read for one.
The --format text and --format json examples below are the output: 'console' rendering. With
the default output: 'axi', the report is an answer, as the end of this section shows. With
output: 'console', the toon report is the one above, except that the next steps leave out the
CLI’s name and nothing installed reads plugins: 0 installed plugins found ..., and a bare
plugins prints the group’s help and exits with code 2.
--format text is the human-readable prose, and it is the one form that varies with the count:
$ my-cli plugins list --format textfound the following plugins:
my-cli-plugin my-cli-plugin-git$ my-cli plugins list --format textfound one plugin: my-cli-plugin$ my-cli plugins list --format textno plugin with keywords: my-cli-plugin--format json emits the payload alone — no help line — so it survives a pipe:
$ my-cli plugins list --format json | jq -r '.plugins[]'my-cli-pluginmy-cli-plugin-gittoon and json report the same shape whatever the count, so a reader never has to branch on how
many results there turned out to be. A value outside the three is a usage error: the CLI reports it
and exits with code 2 rather than quietly falling back to the default.
Since 12.0.0, every CLI has a
global --format. The sub-commands drop their
own --format and report in the global one, so its default is the CLI’s format. On
clibuilder 11.5.0 they declare their own.
Since 12.0.0, with output: 'axi', the default, the sub-commands return their report as the
answer, and the CLI prints it once on stdout. With
output: 'console' they write it through ui as above. --format json
then carries the help list too, and nothing installed or found is an empty state:
$ my-cli plugins list --format json{"plugins":["my-cli-plugin"],"help":["Run `my-cli plugins search` to find more plugins on npm"]}
$ my-cli plugins listempty: 0 installed plugins found with keywords: my-cli-pluginhelp[1]: Run `my-cli plugins search` to find plugins to installAn unknown --fields value is then an error with code invalid-value and exit code 2. A registry
search that fails is an error with code registry-search-failed and exit code 1.
Neither sub-command installs anything or edits your config. Adopting a plugin stays a deliberate, two-step act:
$ my-cli plugins search # find it$ npm install my-cli-plugin # install it # then add it to `plugins` in the config fileTo give your users a plugins install command, or a plugins page on your CLI’s website, see
Plugin Directory.
Writing a plugin
Section titled “Writing a plugin”Export an activate function that takes a PluginActivationContext and calls addCommand. Import
the type from @clibuilder/plugins, as a type-only dev dependency. clibuilder 12 still exports a
deprecated copy, and 13 removes it. A plugin built for 11 loads unchanged.
import { command } from 'clibuilder'import type { PluginActivationContext } from '@clibuilder/plugins'
const sing = command({ name: 'sing', description: 'sing a song', run() { this.ui.info('🎵') }})
const dance = command({ name: 'dance', description: 'dance a dance', run() { this.ui.info('💃') }})
export function activate({ addCommand }: PluginActivationContext) { addCommand({ name: 'miku', description: 'vocaloid commands', commands: [sing, dance] })}addCommand may be called more than once to contribute several top-level commands. The commands it
receives are ordinary commands — arguments, options, sub-commands,
and config schemas all work exactly as they do in the host CLI.
Contributing content or capabilities
Section titled “Contributing content or capabilities”Plugins can also register values that are not commands. Define the key in a small contracts package that both the host and its plugins depend on, then register against that key during activation:
// @my-cli/contractsexport const governances = defineCollectionKey<Governance>('my-cli:governances')
// a pluginexport function activate(ctx: PluginActivationContext) { ctx.register(governances, { name: 'changeset-authoring', load })}Use defineKey<T>() for a capability supplied by one plugin. The first configured plugin to register
that key wins; later registrations are skipped with a warning. Use defineCollectionKey<T>() for
contributions from many plugins. get() returns those in config order, each with its source package:
const allGovernances = this.registry.get(governances)Keys match by their string id, so independently installed copies of the contracts package interoperate.
Read a capability inside run() rather than activate(): all configured plugins have activated before
a command runs, regardless of their order in the config. activate() may return a promise, but should
still stay cheap; register lazy functions rather than loading content up front.
Publishing so plugins search finds you
Section titled “Publishing so plugins search finds you”The host CLI’s keyword goes in your package.json keywords. Without it, your package is invisible
to both plugins list and plugins search, no matter how correct the rest of the plugin is.
{ "name": "my-cli-plugin-git", "description": "git commands for my-cli", "keywords": ["my-cli-plugin", "git"], "type": "module", "exports": { "types": "./esm/index.d.ts", "default": "./esm/index.js" }}Check the host CLI’s docs for the exact keyword. If it is not documented, the CLI’s name is the
default keyword of plugins().
Extra keywords are free, and worth adding: they describe what the plugin does, while the host
keyword describes which CLI it plugs into. Both matter to someone reading a plugins search
result list.
Naming conventions
Section titled “Naming conventions”plugins search returns bare package names, with no descriptions. The name is the entire signal a
user gets, so let it carry the CLI it targets:
| Pattern | Example | Reads as |
|---|---|---|
<cli>-plugin-<feature> |
my-cli-plugin-git |
the git plugin for my-cli |
@scope/<cli>-plugin-<feature> |
@acme/my-cli-plugin-deploy |
Acme’s deploy plugin for my-cli |
<cli>-plugin |
my-cli-plugin |
the one canonical plugin for my-cli |
Two things worth avoiding: a name that says nothing about the host CLI (git-helper in a
my-cli plugins search list is a guessing game), and a name that implies official status you do not
have. Publish under a scope if you want your own namespace.
Whatever you pick, keep the command names your plugin adds distinct from the host’s. Commands are merged into one namespace, and a collision is resolved by load order, not by anything a user can see.
Testing a plugin
Section titled “Testing a plugin”A plugin’s commands are just commands, so
testCommand() tests them directly, without a host CLI:
import { testCommand } from 'clibuilder/testing'import { activate } from '../src/index.js'
test('miku sings', async () => { const commands: any[] = [] activate({ addCommand: cmd => commands.push(cmd) })
const { messages } = await testCommand(commands[0], 'miku sing') expect(messages).toBe('🎵')})Calling activate yourself with a stub addCommand also checks the half of the contract that only
the host normally exercises — that activate is exported, and that it registers what you expect.
To test an extension, the function you pass to .use(), mockExtensionContext() from
clibuilder/testing builds its context and captures what it adds:
import { mockExtensionContext } from 'clibuilder/testing'
const { context, commands, beforeRunHooks } = mockExtensionContext()await myExtension(context)
expect(commands).toHaveLength(1)expect(beforeRunHooks).toHaveLength(0)It also captures global options, the values put in shared and the registry, and the messages written
through ui. See mockExtensionContext().
A plugin written for the deprecated activate(context) contract uses mockPluginContext({ source })
the same way, until clibuilder 13 removes it.