CLI Plugins and Extension Systems
How to design and build a plugin architecture for Node.js CLI tools, including plugin discovery, lifecycle hooks, and sandboxed execution.
CLI Plugins and Extension Systems
The most successful CLI tools — ESLint, Babel, Prettier, the Salesforce CLI — are platforms, not monoliths. Their core is small, and plugins do the real work. A good plugin system lets your community extend your tool without touching your source code, and it keeps your core maintainable as features grow.
I have designed plugin systems for internal tools and open-source CLIs. The patterns are consistent: discover plugins, load them safely, give them hooks into the lifecycle, and keep them isolated from each other. This guide builds all of it from scratch.
Prerequisites
- Node.js installed (v14+)
- Experience building CLI tools
- Understanding of
require()and module resolution - Familiarity with event emitters or hook patterns
Plugin System Architecture
A plugin system has five responsibilities:
- Discovery — finding plugins (npm packages, local files, config entries)
- Loading — requiring plugins and validating their exports
- Registration — plugins register commands, hooks, or transforms
- Lifecycle — calling plugin hooks at the right time
- Isolation — preventing plugins from breaking each other or the host
┌──────────────┐
│ CLI Core │
│ ┌────────┐ │ ┌──────────┐
│ │ Plugin │◀─┼─────│ Plugin A │ (npm package)
│ │ Manager│ │ └──────────┘
│ │ │◀─┼─────┌──────────┐
│ │ │ │ │ Plugin B │ (local file)
│ │ │◀─┼─────└──────────┘
│ │ │ │ ┌──────────┐
│ │ │◀─┼─────│ Plugin C │ (from config)
│ └────────┘ │ └──────────┘
└──────────────┘
Plugin Discovery
Plugins can come from three sources: npm packages with a naming convention, local file paths, and explicit entries in configuration.
var fs = require("fs");
var path = require("path");
var PLUGIN_PREFIX = "mytool-plugin-";
var SCOPED_REGEX = /^@[\w-]+\/mytool-plugin-/;
function discoverPlugins(config) {
var plugins = [];
// Source 1: Explicit config entries
var configPlugins = config.plugins || [];
for (var i = 0; i < configPlugins.length; i++) {
var entry = configPlugins[i];
if (typeof entry === "string") {
plugins.push({ name: entry, options: {} });
} else {
plugins.push({ name: entry.name || entry[0], options: entry.options || entry[1] || {} });
}
}
// Source 2: Auto-discover from node_modules
if (config.autoDiscover !== false) {
var discovered = discoverFromNodeModules();
for (var j = 0; j < discovered.length; j++) {
var already = plugins.some(function(p) { return p.name === discovered[j]; });
if (!already) {
plugins.push({ name: discovered[j], options: {} });
}
}
}
return plugins;
}
function discoverFromNodeModules() {
var found = [];
try {
var nodeModules = path.join(process.cwd(), "node_modules");
if (!fs.existsSync(nodeModules)) return found;
var entries = fs.readdirSync(nodeModules);
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
// Regular packages: mytool-plugin-*
if (entry.indexOf(PLUGIN_PREFIX) === 0) {
found.push(entry);
continue;
}
// Scoped packages: @scope/mytool-plugin-*
if (entry.charAt(0) === "@") {
try {
var scopedEntries = fs.readdirSync(path.join(nodeModules, entry));
for (var j = 0; j < scopedEntries.length; j++) {
if (scopedEntries[j].indexOf(PLUGIN_PREFIX) === 0) {
found.push(entry + "/" + scopedEntries[j]);
}
}
} catch (e) {
// Skip unreadable scoped dirs
}
}
}
} catch (e) {
// node_modules doesn't exist
}
return found;
}
Plugin Loading and Validation
Load plugins safely and validate their exports match your expected interface:
function loadPlugin(nameOrPath, options) {
var modulePath;
// Resolve the module path
if (nameOrPath.charAt(0) === "." || path.isAbsolute(nameOrPath)) {
// Local file
modulePath = path.resolve(nameOrPath);
} else if (nameOrPath.indexOf(PLUGIN_PREFIX) === -1 && nameOrPath.charAt(0) !== "@") {
// Short name: "foo" -> "mytool-plugin-foo"
modulePath = PLUGIN_PREFIX + nameOrPath;
} else {
modulePath = nameOrPath;
}
// Load the module
var pluginModule;
try {
pluginModule = require(modulePath);
} catch (err) {
if (err.code === "MODULE_NOT_FOUND") {
throw new Error(
"Plugin not found: " + modulePath + "\n" +
"Install it with: npm install " + modulePath
);
}
throw new Error("Failed to load plugin " + modulePath + ": " + err.message);
}
// Normalize: support both function and object exports
var plugin;
if (typeof pluginModule === "function") {
plugin = pluginModule(options);
} else {
plugin = pluginModule;
}
// Validate required fields
if (!plugin.name) {
plugin.name = nameOrPath;
}
if (plugin.version && typeof plugin.version !== "string") {
throw new Error("Plugin " + plugin.name + ": version must be a string");
}
// Validate hook types
var validHooks = ["init", "beforeRun", "afterRun", "beforeCommand", "afterCommand", "error", "shutdown"];
if (plugin.hooks) {
var hookNames = Object.keys(plugin.hooks);
for (var i = 0; i < hookNames.length; i++) {
if (validHooks.indexOf(hookNames[i]) === -1) {
console.warn(
"Plugin " + plugin.name + ": unknown hook '" + hookNames[i] + "'"
);
}
if (typeof plugin.hooks[hookNames[i]] !== "function") {
throw new Error(
"Plugin " + plugin.name + ": hook '" + hookNames[i] + "' must be a function"
);
}
}
}
return plugin;
}
The Plugin Interface
Define a clear contract for what plugins can provide:
// Plugin interface definition
/*
A plugin exports an object (or a function that returns an object) with:
{
name: "my-plugin", // Required: unique name
version: "1.0.0", // Optional: semver version
// Commands: add new CLI commands
commands: [
{
name: "my-command",
description: "Does something cool",
options: [
{ name: "--flag", description: "A flag", type: "boolean" }
],
action: function(args, opts, context) { ... }
}
],
// Hooks: intercept lifecycle events
hooks: {
init: function(context) { ... },
beforeCommand: function(command, args, context) { ... },
afterCommand: function(command, result, context) { ... },
error: function(error, context) { ... },
shutdown: function(context) { ... }
},
// Config: plugin-specific configuration schema
config: {
key: { type: "string", default: "value", description: "..." }
}
}
*/
// Example plugin: mytool-plugin-timer
module.exports = function(options) {
var startTimes = {};
return {
name: "timer",
version: "1.0.0",
hooks: {
beforeCommand: function(command, args, context) {
startTimes[command] = Date.now();
if (options.verbose) {
console.log("\u001b[90m[timer] Starting " + command + "\u001b[0m");
}
},
afterCommand: function(command, result, context) {
var elapsed = Date.now() - (startTimes[command] || Date.now());
console.log(
"\u001b[90m[timer] " + command + " completed in " +
elapsed + "ms\u001b[0m"
);
}
}
};
};
The Plugin Manager
The plugin manager orchestrates discovery, loading, and hook execution:
function createPluginManager(cliContext) {
var plugins = [];
var commands = {};
var hookRegistry = {};
function registerHook(hookName, pluginName, fn) {
if (!hookRegistry[hookName]) {
hookRegistry[hookName] = [];
}
hookRegistry[hookName].push({
plugin: pluginName,
handler: fn
});
}
function registerCommand(pluginName, commandDef) {
var fullName = commandDef.name;
if (commands[fullName]) {
console.warn(
"Plugin " + pluginName + ": command '" + fullName +
"' conflicts with " + commands[fullName].plugin
);
return;
}
commands[fullName] = {
plugin: pluginName,
definition: commandDef
};
}
return {
load: function(pluginEntries) {
for (var i = 0; i < pluginEntries.length; i++) {
var entry = pluginEntries[i];
try {
var plugin = loadPlugin(entry.name, entry.options);
plugins.push(plugin);
// Register hooks
if (plugin.hooks) {
var hookNames = Object.keys(plugin.hooks);
for (var h = 0; h < hookNames.length; h++) {
registerHook(hookNames[h], plugin.name, plugin.hooks[hookNames[h]]);
}
}
// Register commands
if (plugin.commands) {
for (var c = 0; c < plugin.commands.length; c++) {
registerCommand(plugin.name, plugin.commands[c]);
}
}
console.log("\u001b[90m Loaded plugin: " + plugin.name +
(plugin.version ? " v" + plugin.version : "") + "\u001b[0m");
} catch (err) {
console.error("\u001b[31m Failed to load plugin " + entry.name +
": " + err.message + "\u001b[0m");
}
}
},
// Execute all handlers for a hook
runHook: function(hookName) {
var args = Array.prototype.slice.call(arguments, 1);
var handlers = hookRegistry[hookName] || [];
var chain = Promise.resolve();
for (var i = 0; i < handlers.length; i++) {
(function(handler) {
chain = chain.then(function() {
try {
var result = handler.handler.apply(null, args);
if (result && typeof result.then === "function") {
return result;
}
return Promise.resolve();
} catch (err) {
console.error(
"\u001b[31m Plugin " + handler.plugin +
" error in " + hookName + ": " + err.message + "\u001b[0m"
);
return Promise.resolve(); // Don't break the chain
}
});
})(handlers[i]);
}
return chain;
},
// Get a plugin-registered command
getCommand: function(name) {
return commands[name] || null;
},
// List all plugin commands
getCommands: function() {
return commands;
},
// List loaded plugins
getPlugins: function() {
return plugins.map(function(p) {
return { name: p.name, version: p.version || "unknown" };
});
}
};
}
Plugin Configuration
Let plugins declare and receive their own configuration:
function mergePluginConfig(pluginSchema, userConfig) {
var result = {};
var schemaKeys = Object.keys(pluginSchema);
for (var i = 0; i < schemaKeys.length; i++) {
var key = schemaKeys[i];
var schema = pluginSchema[key];
var userValue = userConfig[key];
if (userValue !== undefined) {
// Validate type
if (schema.type && typeof userValue !== schema.type) {
console.warn(
"Config " + key + ": expected " + schema.type +
", got " + typeof userValue + ". Using default."
);
result[key] = schema.default;
} else if (schema.enum && schema.enum.indexOf(userValue) === -1) {
console.warn(
"Config " + key + ": must be one of " + schema.enum.join(", ") +
". Using default."
);
result[key] = schema.default;
} else {
result[key] = userValue;
}
} else {
result[key] = schema.default;
}
}
return result;
}
Building Plugins
Here are example plugins that demonstrate the patterns:
Plugin: Custom Command
// mytool-plugin-deploy/index.js
module.exports = function(options) {
options = options || {};
var defaultTarget = options.defaultTarget || "staging";
return {
name: "deploy",
version: "2.0.0",
commands: [
{
name: "deploy",
description: "Deploy the application",
options: [
{ name: "--target", alias: "-t", description: "Deploy target", default: defaultTarget },
{ name: "--dry-run", description: "Preview without deploying", type: "boolean" },
{ name: "--force", description: "Skip confirmation", type: "boolean" }
],
action: function(args, opts, context) {
var target = opts.target || defaultTarget;
if (opts.dryRun) {
console.log(" [dry-run] Would deploy to " + target);
return;
}
console.log(" Deploying to " + target + "...");
// deployment logic
console.log(" \u001b[32m✔ Deployed successfully\u001b[0m");
}
},
{
name: "rollback",
description: "Rollback to previous deployment",
action: function(args, opts, context) {
console.log(" Rolling back...");
}
}
],
hooks: {
init: function(context) {
// Validate deploy config exists
if (!context.config.deploy) {
console.warn(" \u001b[33m[deploy] No deploy configuration found\u001b[0m");
}
}
},
config: {
provider: { type: "string", default: "aws", enum: ["aws", "gcp", "azure"] },
region: { type: "string", default: "us-east-1" }
}
};
};
Plugin: Output Formatter
// mytool-plugin-json-output/index.js
module.exports = function() {
var outputBuffer = [];
return {
name: "json-output",
version: "1.0.0",
hooks: {
init: function(context) {
// Check if JSON output was requested
if (context.flags.output === "json" || context.flags.json) {
context.jsonMode = true;
// Intercept console.log
var originalLog = console.log;
console.log = function() {
var args = Array.prototype.slice.call(arguments);
var message = args.map(String).join(" ");
// Strip ANSI codes
message = message.replace(/\u001b\[[0-9;]*m/g, "");
outputBuffer.push(message);
};
}
},
afterCommand: function(command, result, context) {
if (context.jsonMode) {
// Restore console.log
var output = {
command: command,
success: !result || !result.error,
output: outputBuffer,
timestamp: new Date().toISOString()
};
// Write to original stdout
process.stdout.write(JSON.stringify(output, null, 2) + "\n");
}
}
}
};
};
Plugin: Audit Logger
// mytool-plugin-audit/index.js
var fs = require("fs");
var path = require("path");
var os = require("os");
module.exports = function(options) {
options = options || {};
var logFile = options.logFile || path.join(os.homedir(), ".mytool", "audit.log");
function appendLog(entry) {
var dir = path.dirname(logFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
var line = JSON.stringify(entry) + "\n";
fs.appendFileSync(logFile, line);
}
return {
name: "audit",
version: "1.0.0",
hooks: {
beforeCommand: function(command, args, context) {
appendLog({
event: "command_start",
command: command,
args: args,
user: os.userInfo().username,
cwd: process.cwd(),
timestamp: new Date().toISOString()
});
},
afterCommand: function(command, result, context) {
appendLog({
event: "command_end",
command: command,
success: true,
timestamp: new Date().toISOString()
});
},
error: function(error, context) {
appendLog({
event: "error",
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
}
},
commands: [
{
name: "audit",
description: "Show audit log",
action: function() {
try {
var content = fs.readFileSync(logFile, "utf8");
var lines = content.trim().split("\n").slice(-20);
for (var i = 0; i < lines.length; i++) {
var entry = JSON.parse(lines[i]);
var time = entry.timestamp.split("T")[1].slice(0, 8);
console.log(
" \u001b[90m" + time + "\u001b[0m " +
entry.event + " " + (entry.command || entry.message || "")
);
}
} catch (e) {
console.log(" No audit log found");
}
}
}
]
};
};
Complete Working Example: CLI with Plugin System
#!/usr/bin/env node
var fs = require("fs");
var path = require("path");
// ---- Colors ----
var c = {
reset: "\u001b[0m", bold: "\u001b[1m", dim: "\u001b[90m",
green: "\u001b[32m", red: "\u001b[31m", yellow: "\u001b[33m",
cyan: "\u001b[36m"
};
// ---- Plugin Manager ----
var PLUGIN_PREFIX = "mytool-plugin-";
var loadedPlugins = [];
var pluginCommands = {};
var hooks = {};
function registerHook(name, pluginName, fn) {
if (!hooks[name]) hooks[name] = [];
hooks[name].push({ plugin: pluginName, fn: fn });
}
function runHook(name, args) {
var handlers = hooks[name] || [];
var chain = Promise.resolve();
for (var i = 0; i < handlers.length; i++) {
(function(h) {
chain = chain.then(function() {
try {
var result = h.fn.apply(null, args || []);
return result && result.then ? result : Promise.resolve();
} catch (e) {
console.error(c.red + " Plugin " + h.plugin + " error: " + e.message + c.reset);
return Promise.resolve();
}
});
})(handlers[i]);
}
return chain;
}
function loadPlugins(config) {
var entries = config.plugins || [];
for (var i = 0; i < entries.length; i++) {
var entry = typeof entries[i] === "string"
? { name: entries[i], options: {} }
: { name: entries[i].name || entries[i][0], options: entries[i].options || entries[i][1] || {} };
try {
var moduleName = entry.name;
if (moduleName.indexOf(".") !== 0 && moduleName.indexOf("/") === -1 &&
moduleName.indexOf(PLUGIN_PREFIX) === -1) {
moduleName = PLUGIN_PREFIX + moduleName;
}
var mod = require(path.resolve("node_modules", moduleName));
var plugin = typeof mod === "function" ? mod(entry.options) : mod;
plugin.name = plugin.name || entry.name;
loadedPlugins.push(plugin);
// Register hooks
if (plugin.hooks) {
var hookNames = Object.keys(plugin.hooks);
for (var h = 0; h < hookNames.length; h++) {
registerHook(hookNames[h], plugin.name, plugin.hooks[hookNames[h]]);
}
}
// Register commands
if (plugin.commands) {
for (var j = 0; j < plugin.commands.length; j++) {
var cmd = plugin.commands[j];
pluginCommands[cmd.name] = { plugin: plugin.name, def: cmd };
}
}
console.log(c.dim + " ✓ Plugin: " + plugin.name +
(plugin.version ? " v" + plugin.version : "") + c.reset);
} catch (err) {
if (err.code === "MODULE_NOT_FOUND") {
console.error(c.yellow + " ⚠ Plugin not found: " + entry.name + c.reset);
} else {
console.error(c.red + " ✖ Plugin error (" + entry.name + "): " + err.message + c.reset);
}
}
}
}
// ---- Built-in Commands ----
var builtinCommands = {
plugins: {
description: "List loaded plugins",
action: function() {
console.log("\n " + c.bold + "Loaded plugins:" + c.reset);
if (loadedPlugins.length === 0) {
console.log(" (none)");
}
for (var i = 0; i < loadedPlugins.length; i++) {
var p = loadedPlugins[i];
var cmds = (p.commands || []).map(function(c2) { return c2.name; }).join(", ");
console.log(
" " + c.cyan + p.name + c.reset +
(p.version ? " " + c.dim + "v" + p.version + c.reset : "") +
(cmds ? " " + c.dim + "[" + cmds + "]" + c.reset : "")
);
}
console.log("");
}
},
help: {
description: "Show available commands",
action: function() {
console.log("\n " + c.bold + "mytool" + c.reset + " — Extensible CLI\n");
console.log(" " + c.bold + "Built-in commands:" + c.reset);
var builtinNames = Object.keys(builtinCommands);
for (var i = 0; i < builtinNames.length; i++) {
console.log(" " + c.cyan + builtinNames[i].padEnd(15) + c.reset +
builtinCommands[builtinNames[i]].description);
}
var pluginCmdNames = Object.keys(pluginCommands);
if (pluginCmdNames.length > 0) {
console.log("\n " + c.bold + "Plugin commands:" + c.reset);
for (var j = 0; j < pluginCmdNames.length; j++) {
var pc = pluginCommands[pluginCmdNames[j]];
console.log(
" " + c.cyan + pluginCmdNames[j].padEnd(15) + c.reset +
pc.def.description +
c.dim + " (" + pc.plugin + ")" + c.reset
);
}
}
console.log("");
}
},
version: {
description: "Show version",
action: function() {
var pkg = require(path.join(process.cwd(), "package.json"));
console.log(pkg.version || "unknown");
}
}
};
// ---- Argument Parsing ----
function parseArgs() {
var args = process.argv.slice(2);
var command = null;
var flags = {};
var positional = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg === "--help" || arg === "-h") { flags.help = true; continue; }
if (arg.indexOf("--") === 0) {
var eq = arg.indexOf("=");
if (eq !== -1) { flags[arg.slice(2, eq)] = arg.slice(eq + 1); }
else if (i + 1 < args.length && args[i + 1].charAt(0) !== "-") { flags[arg.slice(2)] = args[++i]; }
else { flags[arg.slice(2)] = true; }
continue;
}
if (!command) command = arg;
else positional.push(arg);
}
return { command: command, flags: flags, positional: positional };
}
// ---- Main ----
function main() {
var parsed = parseArgs();
// Load config
var configPath = path.join(process.cwd(), ".mytoolrc");
var config = {};
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch (e) {
// No config file
}
// Load plugins
if (config.plugins && config.plugins.length > 0) {
loadPlugins(config);
}
// Create context
var context = {
config: config,
flags: parsed.flags,
cwd: process.cwd()
};
// Run init hooks
runHook("init", [context]).then(function() {
// Resolve command
var commandName = parsed.command || "help";
if (parsed.flags.help && !parsed.command) {
commandName = "help";
}
var handler = builtinCommands[commandName] || null;
var isPlugin = false;
if (!handler && pluginCommands[commandName]) {
handler = pluginCommands[commandName].def;
isPlugin = true;
}
if (!handler) {
console.error(c.red + "\n Unknown command: " + commandName + c.reset);
console.error(" Run " + c.cyan + "mytool help" + c.reset + " for available commands\n");
process.exit(1);
}
// Run beforeCommand hooks
return runHook("beforeCommand", [commandName, parsed.positional, context]).then(function() {
// Execute command
var result;
try {
result = handler.action(parsed.positional, parsed.flags, context);
} catch (err) {
return runHook("error", [err, context]).then(function() {
console.error(c.red + "\n Error: " + err.message + c.reset + "\n");
process.exit(1);
});
}
var completion = result && result.then ? result : Promise.resolve(result);
return completion.then(function(res) {
return runHook("afterCommand", [commandName, { result: res }, context]);
}).catch(function(err) {
return runHook("error", [err, context]).then(function() {
console.error(c.red + "\n Error: " + err.message + c.reset + "\n");
process.exit(1);
});
});
});
}).then(function() {
return runHook("shutdown", [context]);
});
}
main();
Config file (.mytoolrc):
{
"plugins": [
"timer",
["deploy", { "defaultTarget": "staging" }],
"audit"
]
}
Running it:
$ mytool help
✓ Plugin: timer v1.0.0
✓ Plugin: deploy v2.0.0
✓ Plugin: audit v1.0.0
mytool — Extensible CLI
Built-in commands:
plugins List loaded plugins
help Show available commands
version Show version
Plugin commands:
deploy Deploy the application (deploy)
rollback Rollback to previous deployment (deploy)
audit Show audit log (audit)
$ mytool deploy --target production
[timer] Starting deploy
Deploying to production...
✔ Deployed successfully
[timer] deploy completed in 234ms
Common Issues and Troubleshooting
Plugin loads but commands are not available
The plugin's commands array is not structured correctly:
Fix: Verify each command object has name, description, and action fields. Log the raw plugin export to debug.
Hook execution order is unpredictable
Plugins are loaded in config order, but async hooks may complete in any order:
Fix: Run hooks sequentially (as shown in the example) using promise chaining. If you need parallel hooks, document it clearly.
Plugin modifies global state and breaks other plugins
A plugin monkey-patches console.log or modifies process.env:
Fix: Wrap plugin execution in a try/catch. Provide a scoped context object instead of letting plugins access globals. Consider running plugins in a separate vm context for full isolation.
Plugin requires a different version of a shared dependency
Two plugins need different versions of the same package:
Fix: Each plugin should bundle its own dependencies. Use npm's nested node_modules resolution or recommend that plugins vendor their deps.
Best Practices
- Define a clear plugin interface and document it. Plugins need a spec to follow. Publish an interface definition and a plugin template repository.
- Load plugins lazily when possible. Only require plugin modules when their commands or hooks are actually needed.
- Run hooks sequentially by default. Parallel hook execution leads to race conditions. Let plugins opt in to parallel if they need it.
- Fail gracefully on plugin errors. A broken plugin should not crash the entire CLI. Log the error and continue.
- Provide a plugin scaffold command.
mytool create-plugin my-plugingenerates a starter plugin with the correct structure. - Version your plugin API. When you change the plugin interface, bump the API version and check compatibility during plugin loading.
- Keep the core small. Move features that not everyone needs into optional plugins. The core should handle argument parsing, plugin loading, and lifecycle management.