Cli Tools

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:

  1. Discovery — finding plugins (npm packages, local files, config entries)
  2. Loading — requiring plugins and validating their exports
  3. Registration — plugins register commands, hooks, or transforms
  4. Lifecycle — calling plugin hooks at the right time
  5. 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-plugin generates 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.

References

Powered by Contentful