How to Add a Plugin System to Your Node.js CLI Tool
The best CLI tools don't try to do everything — they let users extend them. ESLint has plugins for every framework. Prettier has parsers for every language. Babel has transforms for every syntax. Behind each of these is a plugin system that turns a good tool into a platform.
Building a plugin system sounds complex, but the core pattern is simple: discover plugins, load them, and call their hooks at the right time. This article shows you how to build one from scratch.
What Makes a Good Plugin System
Before writing code, let's define what we need:
-
Discovery — find plugins in
node_modulesor local paths - Loading — import and validate plugin modules
- Hooks — defined points where plugins can inject behavior
- Configuration — per-plugin settings
- Ordering — plugins run in a predictable sequence
The Plugin Interface
Start with a TypeScript interface that defines what a plugin looks like:
// src/types.ts
export interface Plugin {
name: string;
version?: string;
// Lifecycle hooks
setup?(context: PluginContext): void | Promise<void>;
beforeRun?(context: RunContext): void | Promise<void>;
transform?(data: unknown, context: RunContext): unknown | Promise<unknown>;
afterRun?(results: unknown, context: RunContext): void | Promise<void>;
teardown?(): void | Promise<void>;
}
export interface PluginContext {
config: Record<string, unknown>;
logger: Logger;
registerCommand?(name: string, handler: CommandHandler): void;
}
export interface RunContext extends PluginContext {
args: Record<string, unknown>;
results: Map<string, unknown>;
}
export type PluginFactory = (options?: Record<string, unknown>) => Plugin;
Every hook is optional. Plugins only implement what they need. This is the same pattern ESLint and Rollup use — minimal interface, maximum flexibility.
Plugin Discovery
Plugins can come from three places:
// src/plugin-loader.ts
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
export async function discoverPlugins(config: ToolConfig): Promise<string[]> {
const plugins: string[] = [];
// 1. Explicit config: plugins listed in config file
if (config.plugins) {
plugins.push(...config.plugins);
}
// 2. Convention: packages matching "mytool-plugin-*"
try {
const pkg = JSON.parse(
await readFile(join(process.cwd(), 'package.json'), 'utf-8')
);
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies,
};
for (const dep of Object.keys(allDeps)) {
if (dep.startsWith('mytool-plugin-') || dep.startsWith('@*/mytool-plugin-')) {
if (!plugins.includes(dep)) {
plugins.push(dep);
}
}
}
} catch {}
// 3. Local plugins: relative paths in config
// Already handled by config.plugins above
return plugins;
}
The convention-based discovery (mytool-plugin-*) is powerful — install a package and it's automatically loaded. This is how Gatsby, Docusaurus, and many other tools work.
Plugin Loading and Validation
export async function loadPlugin(
nameOrPath: string,
options?: Record<string, unknown>
): Promise<Plugin> {
let module: unknown;
try {
if (nameOrPath.startsWith('.') || nameOrPath.startsWith('/')) {
// Local plugin
module = await import(resolve(process.cwd(), nameOrPath));
} else {
// npm package
module = await import(nameOrPath);
}
} catch (error) {
throw new Error(
`Failed to load plugin "${nameOrPath}": ${error.message}\n` +
` Make sure the package is installed: npm install ${nameOrPath}`
);
}
// Support both default exports and factory functions
const factory = (module as any).default || module;
let plugin: Plugin;
if (typeof factory === 'function') {
plugin = factory(options);
} else if (typeof factory === 'object' && factory.name) {
plugin = factory as Plugin;
} else {
throw new Error(
`Plugin "${nameOrPath}" must export a function or an object with a "name" property`
);
}
// Validate required fields
if (!plugin.name || typeof plugin.name !== 'string') {
throw new Error(`Plugin "${nameOrPath}" must have a "name" property`);
}
return plugin;
}
The Plugin Manager
The plugin manager orchestrates discovery, loading, and hook execution:
// src/plugin-manager.ts
export class PluginManager {
private plugins: Plugin[] = [];
private context: PluginContext;
constructor(config: ToolConfig) {
this.context = {
config,
logger: createLogger(config.verbose),
};
}
async loadAll(pluginConfigs: PluginConfig[]): Promise<void> {
for (const pc of pluginConfigs) {
const name = typeof pc === 'string' ? pc : pc.name;
const options = typeof pc === 'string' ? {} : pc.options;
try {
const plugin = await loadPlugin(name, options);
this.plugins.push(plugin);
this.context.logger.debug(`Loaded plugin: ${plugin.name}`);
} catch (error) {
this.context.logger.error(`Failed to load plugin: ${error.message}`);
if (!this.context.config.ignorePluginErrors) {
throw error;
}
}
}
}
async runHook<K extends keyof Plugin>(
hook: K,
...args: Parameters<NonNullable<Plugin[K]>>
): Promise<void> {
for (const plugin of this.plugins) {
const fn = plugin[hook];
if (typeof fn === 'function') {
try {
await (fn as Function).call(plugin, ...args);
} catch (error) {
this.context.logger.error(
`Plugin "${plugin.name}" failed in ${String(hook)}: ${error.message}`
);
throw error;
}
}
}
}
async runTransform(data: unknown, context: RunContext): Promise<unknown> {
let result = data;
for (const plugin of this.plugins) {
if (plugin.transform) {
result = await plugin.transform(result, context);
}
}
return result;
}
}
Using Plugins in Your CLI
// src/cli.ts
const manager = new PluginManager(config);
// Load plugins from config
await manager.loadAll(config.plugins || []);
// Setup phase
await manager.runHook('setup', context);
// Before run
await manager.runHook('beforeRun', runContext);
// Main execution
let results = await runMainTask(args);
// Transform results through plugins
results = await manager.runTransform(results, runContext);
// After run
await manager.runHook('afterRun', results, runContext);
// Cleanup
await manager.runHook('teardown');
Writing a Plugin
Here's what a plugin looks like from the author's perspective:
// mytool-plugin-json-report/index.ts
import { writeFile } from 'node:fs/promises';
import type { Plugin } from 'mytool';
export default function jsonReportPlugin(options = {}): Plugin {
const outputPath = options.output || 'report.json';
return {
name: 'json-report',
version: '1.0.0',
afterRun(results, context) {
const report = {
timestamp: new Date().toISOString(),
results,
config: context.config,
};
await writeFile(outputPath, JSON.stringify(report, null, 2));
context.logger.info(`Report written to ${outputPath}`);
},
};
}
Configuration in the user's config file:
{
"plugins": [
"mytool-plugin-json-report",
["mytool-plugin-slack-notify", { "webhook": "https://hooks.slack.com/..." }],
"./my-custom-plugin.js"
]
}
Plugin Configuration Patterns
Support three config styles (like ESLint):
type PluginConfig =
| string // Just the name
| [string, Record<string, unknown>] // Name + options (array tuple)
| { name: string; options?: Record<string, unknown> }; // Object form
function normalizePluginConfig(config: PluginConfig): { name: string; options: Record<string, unknown> } {
if (typeof config === 'string') {
return { name: config, options: {} };
}
if (Array.isArray(config)) {
return { name: config[0], options: config[1] || {} };
}
return { name: config.name, options: config.options || {} };
}
Conclusion
A plugin system transforms your CLI from a tool into a platform. The pattern is straightforward: define an interface, discover and load modules, call hooks at lifecycle points. Start simple — even just a transform hook is enough to be useful — and expand as users request more extension points.
Wilson Xu builds extensible developer tools. Find his work at dev.to/chengyixu and npm.
Top comments (0)