DEV Community

Wilson Xu
Wilson Xu

Posted on

Building Extensible CLI Tools with Plugin Systems in Node.js

Building Extensible CLI Tools with Plugin Systems in Node.js

Command-line tools start simple. A handful of commands, a few flags, a focused purpose. Then users arrive with requests you never anticipated. One wants CSV output. Another needs integration with their proprietary API. A third wants to hook into the build pipeline at a specific moment. You can say no, or you can build a plugin system that lets them solve their own problems.

Plugin architectures transform CLI tools from rigid utilities into platforms. ESLint, Babel, Webpack, and Prettier all owe their dominance partly to their extensibility. This article walks through building a complete plugin system for a Node.js CLI tool, covering discovery, lifecycle hooks, configuration, TypeScript APIs, and security sandboxing.

Why Plugins Matter

A CLI tool without plugins is a closed system. Every feature request becomes a pull request. Every edge case becomes your problem. Plugin systems invert this relationship: you define the boundaries, and users fill in the details.

The benefits go beyond reducing your maintenance burden:

  • Community growth. Plugin ecosystems attract contributors who might never touch your core codebase.
  • Separation of concerns. Core logic stays lean while optional functionality lives in separate packages.
  • Customization without forking. Enterprise users can adapt the tool to internal workflows without maintaining a fork.
  • Faster iteration. Plugin authors can ship updates independently of the core release cycle.

The tradeoff is complexity. A plugin system introduces contracts you must maintain, versioning concerns, and security boundaries. But for any CLI tool that aspires to broad adoption, the investment pays for itself.

Plugin Discovery: Scanning for Packages

The first problem is finding plugins. Your CLI needs to know which plugins are installed without requiring users to manually register each one. The convention popularized by ESLint and Babel is prefix-based discovery: any npm package matching a naming pattern is a potential plugin.

const fs = require('fs');
const path = require('path');

function discoverPlugins(prefix) {
  const nodeModulesPath = path.join(process.cwd(), 'node_modules');

  if (!fs.existsSync(nodeModulesPath)) {
    return [];
  }

  const discovered = [];
  const entries = fs.readdirSync(nodeModulesPath);

  for (const entry of entries) {
    // Handle regular packages: mycli-plugin-*
    if (entry.startsWith(`${prefix}-plugin-`)) {
      discovered.push(entry);
    }

    // Handle scoped packages: @scope/mycli-plugin-*
    if (entry.startsWith('@')) {
      const scopedPath = path.join(nodeModulesPath, entry);
      const scopedEntries = fs.readdirSync(scopedPath);
      for (const scopedEntry of scopedEntries) {
        if (scopedEntry.startsWith(`${prefix}-plugin-`)) {
          discovered.push(`${entry}/${scopedEntry}`);
        }
      }
    }
  }

  return discovered;
}
Enter fullscreen mode Exit fullscreen mode

This approach handles both regular packages (mycli-plugin-typescript) and scoped packages (@myorg/mycli-plugin-auth). The naming convention does double duty: it serves as a discovery mechanism and communicates intent to anyone browsing node_modules.

For more sophisticated discovery, you can also check the keywords field in each package's package.json:

function validatePlugin(packageName) {
  const pkgPath = path.join(
    process.cwd(), 'node_modules', packageName, 'package.json'
  );
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));

  return pkg.keywords && pkg.keywords.includes('mycli-plugin');
}
Enter fullscreen mode Exit fullscreen mode

This two-layer approach -- naming convention plus keyword validation -- reduces false positives while keeping discovery automatic.

The Plugin Lifecycle

Every plugin system needs a lifecycle: defined moments when plugins can run code. A minimal lifecycle for a CLI tool looks like this:

  1. init -- Plugin sets up resources, registers commands, or modifies configuration.
  2. execute -- Plugin performs its primary work during the tool's main execution phase.
  3. cleanup -- Plugin releases resources, writes final output, or reports results.

Here is a concrete implementation:

class PluginManager {
  constructor() {
    this.plugins = new Map();
    this.context = {};
  }

  register(name, plugin) {
    if (typeof plugin.init !== 'function') {
      throw new Error(`Plugin "${name}" must export an init function`);
    }
    this.plugins.set(name, {
      instance: plugin,
      initialized: false,
    });
  }

  async initAll(config) {
    this.context = {
      config,
      logger: console,
      registry: new Map(),
    };

    for (const [name, entry] of this.plugins) {
      try {
        await entry.instance.init(this.context);
        entry.initialized = true;
      } catch (err) {
        this.context.logger.error(
          `Plugin "${name}" failed to initialize: ${err.message}`
        );
      }
    }
  }

  async executeAll(phase, data) {
    const results = [];

    for (const [name, entry] of this.plugins) {
      if (!entry.initialized) continue;
      if (typeof entry.instance[phase] !== 'function') continue;

      try {
        const result = await entry.instance[phase](data, this.context);
        results.push({ name, result });
      } catch (err) {
        this.context.logger.error(
          `Plugin "${name}" failed during "${phase}": ${err.message}`
        );
      }
    }

    return results;
  }

  async cleanupAll() {
    for (const [name, entry] of this.plugins) {
      if (!entry.initialized) continue;
      if (typeof entry.instance.cleanup !== 'function') continue;

      try {
        await entry.instance.cleanup(this.context);
      } catch (err) {
        this.context.logger.error(
          `Plugin "${name}" failed during cleanup: ${err.message}`
        );
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that every lifecycle method is wrapped in try-catch. A misbehaving plugin should never crash the host tool. This defensive approach is non-negotiable in plugin architectures.

Sharing State via Context Objects

The context object in the code above is the communication channel between your CLI and its plugins. It is also how plugins communicate with each other. Designing this object well is critical.

A good context provides:

  • Configuration -- the resolved settings for the current run.
  • A logger -- so plugins produce consistent output.
  • A registry -- for plugins to expose services to other plugins.
  • Hooks -- for plugins to tap into specific moments in execution.
class PluginContext {
  constructor(config) {
    this.config = Object.freeze({ ...config });
    this.logger = this.createLogger();
    this.registry = new Map();
    this.hooks = new Map();
  }

  createLogger() {
    const prefix = (level) => `[${level}]`;
    return {
      info: (msg) => console.log(`${prefix('INFO')} ${msg}`),
      warn: (msg) => console.warn(`${prefix('WARN')} ${msg}`),
      error: (msg) => console.error(`${prefix('ERROR')} ${msg}`),
      debug: (msg) => {
        if (this.config.debug) {
          console.log(`${prefix('DEBUG')} ${msg}`);
        }
      },
    };
  }

  // Let plugins register services for other plugins
  provide(key, service) {
    if (this.registry.has(key)) {
      throw new Error(`Service "${key}" is already registered`);
    }
    this.registry.set(key, service);
  }

  // Let plugins consume services from other plugins
  consume(key) {
    if (!this.registry.has(key)) {
      throw new Error(`Service "${key}" is not registered`);
    }
    return this.registry.get(key);
  }

  // Tapable-style hooks
  registerHook(name) {
    if (!this.hooks.has(name)) {
      this.hooks.set(name, []);
    }
  }

  tapHook(name, fn) {
    if (!this.hooks.has(name)) {
      throw new Error(`Hook "${name}" does not exist`);
    }
    this.hooks.get(name).push(fn);
  }

  async callHook(name, ...args) {
    const fns = this.hooks.get(name) || [];
    for (const fn of fns) {
      await fn(...args);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Freezing the configuration object prevents plugins from mutating shared state. The provide/consume pattern gives plugins a controlled way to share services -- a formatter plugin might provide a formatter service that an output plugin consumes.

Plugin Configuration

Plugins need configuration too. There are two common approaches: .rc files and package.json fields.

The .rc file approach uses a dedicated configuration file (JSON, YAML, or JavaScript):

// .myclirc.js
module.exports = {
  plugins: {
    'mycli-plugin-typescript': {
      tsconfig: './tsconfig.json',
      strict: true,
    },
    'mycli-plugin-output': {
      format: 'json',
      destination: './reports',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

The package.json approach embeds configuration in the project's existing manifest:

{
  "name": "my-project",
  "mycli": {
    "plugins": {
      "mycli-plugin-typescript": {
        "tsconfig": "./tsconfig.json"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Use a tool like cosmiconfig to support both approaches without writing the resolution logic yourself:

const { cosmiconfig } = require('cosmiconfig');

async function loadConfig(toolName) {
  const explorer = cosmiconfig(toolName);
  const result = await explorer.search();

  if (!result || result.isEmpty) {
    return { plugins: {} };
  }

  return result.config;
}
Enter fullscreen mode Exit fullscreen mode

This searches for .myclirc, .myclirc.json, .myclirc.yaml, .myclirc.js, mycli.config.js, and the mycli key in package.json -- all automatically.

Defining a Plugin API with TypeScript

A TypeScript interface is the clearest way to communicate what your plugin system expects. It serves as documentation, enables IDE autocompletion, and catches errors at compile time.

interface PluginContext {
  config: Readonly<Record<string, unknown>>;
  logger: Logger;
  provide(key: string, service: unknown): void;
  consume<T>(key: string): T;
  tapHook(name: string, fn: HookFunction): void;
}

interface Logger {
  info(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  debug(message: string): void;
}

type HookFunction = (...args: unknown[]) => Promise<void> | void;

interface Plugin {
  /** Human-readable name for error messages and logging */
  name: string;

  /** Semver range of the host CLI this plugin is compatible with */
  compatibleVersions: string;

  /** Called once during startup. Register hooks and services here. */
  init(context: PluginContext): Promise<void> | void;

  /** Called during the main execution phase. Optional. */
  execute?(data: unknown, context: PluginContext): Promise<unknown>;

  /** Called during shutdown. Release resources here. Optional. */
  cleanup?(context: PluginContext): Promise<void> | void;
}

// What a plugin module must default-export
type PluginFactory = (options?: Record<string, unknown>) => Plugin;
Enter fullscreen mode Exit fullscreen mode

A plugin author's experience then looks like this:

import type { PluginFactory } from 'mycli';

const createPlugin: PluginFactory = (options = {}) => ({
  name: 'mycli-plugin-metrics',
  compatibleVersions: '>=2.0.0 <3.0.0',

  init(context) {
    context.tapHook('beforeProcess', async (files) => {
      context.logger.info(`Processing ${files.length} files`);
    });
  },

  async execute(data, context) {
    const startTime = Date.now();
    // Plugin logic here
    const elapsed = Date.now() - startTime;
    context.logger.info(`Metrics: completed in ${elapsed}ms`);
    return { elapsed };
  },

  cleanup(context) {
    context.logger.debug('Metrics plugin cleaned up');
  },
});

export default createPlugin;
Enter fullscreen mode Exit fullscreen mode

The PluginFactory pattern -- a function that returns a plugin object -- lets users pass options at the configuration level. This is the same pattern Babel uses for its plugins.

Versioning and Compatibility

Plugin APIs are contracts. Breaking them breaks every plugin in the ecosystem. Semantic versioning is essential, but you need to enforce it.

const semver = require('semver');
const { version: hostVersion } = require('./package.json');

function checkCompatibility(plugin) {
  if (!plugin.compatibleVersions) {
    console.warn(
      `Plugin "${plugin.name}" does not declare compatible versions`
    );
    return true; // Allow but warn
  }

  if (!semver.satisfies(hostVersion, plugin.compatibleVersions)) {
    throw new Error(
      `Plugin "${plugin.name}" requires CLI version ` +
      `${plugin.compatibleVersions}, but current version is ${hostVersion}`
    );
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Beyond runtime checks, communicate clearly what constitutes a breaking change in your plugin API. Document which context methods, hook names, and data shapes are part of the public API versus internal implementation details.

A good practice borrowed from the Webpack project: maintain a MIGRATION.md file that explains exactly what changed between major versions and how plugin authors should update their code.

Security: Sandboxing Plugin Code

Plugins are third-party code running with full access to the Node.js runtime. This is a real risk. While full sandboxing is difficult in Node.js, you can limit the damage.

Approach 1: Restrict filesystem access with a proxy.

const fs = require('fs');
const path = require('path');

function createSandboxedFs(allowedRoot) {
  const resolved = path.resolve(allowedRoot);

  return new Proxy(fs, {
    get(target, prop) {
      const original = target[prop];
      if (typeof original !== 'function') return original;

      return function (...args) {
        if (typeof args[0] === 'string') {
          const targetPath = path.resolve(args[0]);
          if (!targetPath.startsWith(resolved)) {
            throw new Error(
              `Plugin attempted to access "${targetPath}" ` +
              `outside allowed root "${resolved}"`
            );
          }
        }
        return original.apply(target, args);
      };
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Approach 2: Run plugins in a worker thread with limited globals.

const { Worker } = require('worker_threads');

function runPluginIsolated(pluginPath, data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(`
      const { parentPort, workerData } = require('worker_threads');

      // Remove dangerous globals
      delete globalThis.process.env;

      const plugin = require(workerData.pluginPath);
      const result = plugin.execute(workerData.data);

      Promise.resolve(result).then(
        (val) => parentPort.postMessage({ ok: true, value: val }),
        (err) => parentPort.postMessage({ ok: false, error: err.message })
      );
    `, {
      eval: true,
      workerData: { pluginPath, data },
      resourceLimits: {
        maxOldGenerationSizeMb: 128,
        maxYoungGenerationSizeMb: 32,
        codeRangeSizeMb: 16,
      },
    });

    worker.on('message', (msg) => {
      if (msg.ok) resolve(msg.value);
      else reject(new Error(msg.error));
    });

    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Worker threads provide genuine isolation, including memory limits via resourceLimits. The tradeoff is performance overhead and restrictions on what data you can pass between threads (it must be serializable).

Approach 3: Permission-based model.

Inspired by Deno's permission system, you can require plugins to declare what they need:

interface PluginPermissions {
  filesystem?: { read?: string[]; write?: string[] };
  network?: { hosts?: string[] };
  env?: { variables?: string[] };
}

interface Plugin {
  name: string;
  permissions: PluginPermissions;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Then enforce those declarations at runtime. This does not provide true sandboxing, but it makes the trust model explicit and auditable.

Real-World Patterns

The best way to understand plugin architecture is to study tools that have done it well.

ESLint uses a rule-based plugin system. Each plugin exports a rules object where keys are rule names and values are objects with create methods. The create method receives a context object and returns a visitor pattern -- an object whose keys are AST node types and whose values are functions called when those nodes are encountered. This tight interface makes rules composable and predictable.

Babel uses a visitor pattern as well, but for AST transformation rather than analysis. Babel plugins are factory functions that receive an api object (providing types, template utilities, and version info) and return a visitor. The factory function pattern enables per-plugin configuration.

Webpack uses a sophisticated hook system built on its tapable library. Plugins are classes with an apply method that receives the compiler instance. The compiler exposes dozens of hooks (asynchronous, synchronous, waterfall, bail) that plugins can tap into. This granularity allows webpack plugins to modify nearly every aspect of the bundling process, but it also makes the plugin API surface large and complex.

The lesson from all three: constrain the interface. ESLint plugins can only define rules. Babel plugins can only transform AST nodes. Even webpack, with its broad hook surface, channels all plugin interaction through a single apply method. The tighter the interface, the more maintainable the ecosystem.

Putting It All Together

Here is a complete, minimal plugin system that incorporates the patterns discussed above:

const fs = require('fs');
const path = require('path');
const semver = require('semver');
const { cosmiconfig } = require('cosmiconfig');
const { version: HOST_VERSION } = require('./package.json');

class CLI {
  constructor(name) {
    this.name = name;
    this.pluginManager = new PluginManager(name);
  }

  async run(argv) {
    // Load configuration
    const explorer = cosmiconfig(this.name);
    const configResult = await explorer.search();
    const config = configResult?.config || {};

    // Discover and load plugins
    const discovered = discoverPlugins(this.name);
    const explicit = Object.keys(config.plugins || {});
    const allPlugins = [...new Set([...discovered, ...explicit])];

    for (const pluginName of allPlugins) {
      const pluginModule = require(pluginName);
      const pluginConfig = config.plugins?.[pluginName] || {};
      const factory = pluginModule.default || pluginModule;
      const plugin = factory(pluginConfig);

      // Version check
      if (plugin.compatibleVersions) {
        if (!semver.satisfies(HOST_VERSION, plugin.compatibleVersions)) {
          console.warn(
            `Skipping "${plugin.name}": requires ${plugin.compatibleVersions}`
          );
          continue;
        }
      }

      this.pluginManager.register(pluginName, plugin);
    }

    // Run lifecycle
    await this.pluginManager.initAll(config);
    const data = this.parseArgs(argv);
    await this.pluginManager.executeAll('execute', data);
    await this.pluginManager.cleanupAll();
  }

  parseArgs(argv) {
    // Argument parsing logic
    return { args: argv.slice(2) };
  }
}

// Usage
const cli = new CLI('mycli');
cli.run(process.argv).catch((err) => {
  console.error(err.message);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

This is approximately 30 lines of integration code on top of the PluginManager and discoverPlugins functions defined earlier. That is the goal: a plugin system should be a thin layer, not a framework.

Practical Guidance

A few hard-won lessons from building and maintaining plugin systems:

Start with fewer hooks. You can always add hooks; removing them is a breaking change. Begin with init, execute, and cleanup. Add granular hooks only when real plugins need them.

Provide a plugin template. A create-mycli-plugin generator or a GitHub template repository lowers the barrier to entry. Include testing utilities that let plugin authors verify their plugin against your API without running the full CLI.

Test backward compatibility. Maintain a suite of "canary" plugins that exercise the public API. Run them against every release candidate. This is your early warning system for accidental breaking changes.

Document the context object exhaustively. The context is the surface area of your plugin API. Every method, every property, every hook name should be documented with types, descriptions, and examples.

Version the plugin API independently of the CLI. Your CLI might ship a patch release that changes no plugin-facing code. Keep a separate pluginApiVersion field so plugin authors know exactly what they are targeting.

Conclusion

A plugin system is a bet on your community. You are trading simplicity for extensibility, control for collaboration. The technical implementation -- discovery, lifecycle hooks, context objects, TypeScript interfaces -- is the straightforward part. The harder work is designing contracts you can maintain for years, writing documentation clear enough that strangers can build on your code, and resisting the urge to expose every internal as a hook.

Start small. Ship the plugin API only when you have concrete use cases, not as a speculative feature. Let real plugins stress-test your design before you commit to stability guarantees. And when you do commit, treat your plugin API with the same seriousness as any public API: versioned, documented, and tested.

The CLI tools that endure are the ones that let their users become co-authors.

Top comments (0)