DEV Community

Wilson Xu
Wilson Xu

Posted on

How to Add a Plugin System to Your Node.js CLI Tool

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:

  1. Discovery — find plugins in node_modules or local paths
  2. Loading — import and validate plugin modules
  3. Hooks — defined points where plugins can inject behavior
  4. Configuration — per-plugin settings
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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}`);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 || {} };
}
Enter fullscreen mode Exit fullscreen mode

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)