Bunli
Core Concepts

Plugins

Extend Bunli's functionality with a powerful plugin system

Overview

Plugins in Bunli can:

  • Modify CLI configuration during setup
  • Register new commands dynamically
  • Hook into command lifecycle events
  • Share type-safe data through a store system
  • Extend core interfaces via module augmentation

Creating a Plugin

Basic Plugin Structure

A plugin is an object that implements the BunliPlugin interface:

import { createPlugin } from "@bunli/core/plugin";

const myPlugin = createPlugin({
  name: "my-plugin",
  version: "1.0.0",

  // Define type-safe store
  store: {
    count: 0,
    message: "" as string,
  },

  // Lifecycle hooks
  setup(context) {
    console.log("Plugin setup");
  },

  beforeCommand(context) {
    context.store.count++;
  },
});

Plugin with Options

For configurable plugins, use a factory function:

import { createPlugin } from "@bunli/core/plugin";

interface MyPluginOptions {
  prefix: string;
  verbose?: boolean;
}

interface MyPluginStore {
  messages: string[];
}

const myPlugin = createPlugin<MyPluginOptions, MyPluginStore>((options) => ({
  name: "my-plugin",

  store: {
    messages: [] as string[],
  },

  beforeCommand(context) {
    const message = `${options.prefix}: Command starting`;
    context.store.messages = [...context.store.messages, message];

    if (options.verbose) {
      console.log(message);
    }
  },
}));

// Usage
myPlugin({ prefix: "APP", verbose: true });

Plugin Lifecycle

Plugins go through several lifecycle stages:

1. Setup Phase

The setup hook runs during CLI initialization. Use it to:

  • Modify configuration
  • Register commands
  • Initialize resources
setup(context) {
  // Update configuration
  context.updateConfig({
    description: 'Enhanced by my plugin'
  })

  // Register a command
  context.registerCommand(defineCommand({
    name: 'plugin-command',
    description: 'A command registered by a plugin',
    handler: async () => {
      console.log('Command from plugin!')
    }
  }))
}

2. Config Resolved

The configResolved hook runs after all configuration is finalized:

configResolved(config) {
  console.log(`CLI initialized: ${config.name} v${config.version}`)
}

3. Before Command

The beforeCommand hook runs before each command execution:

beforeCommand(context) {
  console.log(`Running command: ${context.command}`)
  console.log(`Arguments: ${context.args.join(', ')}`)
}

4. Pre-Run

The preRun hook runs immediately before the command handler executes. It receives an ExecutionState for sharing per-run data between plugins:

preRun(context, state) {
  // Store start time in execution state (shared with postRun)
  state.set('startTime', Date.now())
  console.log(`About to run command: ${context.command}`)
}

5. Post-Run

The postRun hook runs immediately after the command handler completes. It receives the same ExecutionState from preRun:

postRun(context, state) {
  const startTime = state.get<number>('startTime')
  if (startTime) {
    console.log(`Command took ${Date.now() - startTime}ms`)
  }
  console.log(`Command result: ${context.result}`)
}

6. After Command

The afterCommand hook runs after command execution (after postRun):

afterCommand(context) {
  if (context.error) {
    console.error(`Command failed: ${context.error.message}`)
  } else {
    console.log('Command completed successfully')
  }
}

Full lifecycle order: setupconfigResolvedbeforeCommandpreRun[handler]postRunafterCommand

Type-Safe Store System

The store system provides compile-time type safety for sharing data between plugins and commands.

Defining Store Types

interface TimingStore {
  startTime: number | null;
  duration: number | null;
}

const timingPlugin = createPlugin<TimingStore>({
  name: "timing",

  store: {
    startTime: null,
    duration: null,
  },

  beforeCommand(context) {
    context.store.startTime = Date.now();
  },

  afterCommand(context) {
    const startTime = context.store.startTime;
    if (startTime != null) {
      context.store.duration = Date.now() - startTime;
    }
  },
});

Accessing Store in Commands

const cli = await createCLI({
  name: "my-cli",
  plugins: [timingPlugin] as const, // Use 'as const' for better inference
});

cli.command(
  defineCommand({
    name: "info",
    description: "Show timing information (if available)",
    handler: async ({ context }) => {
      // TypeScript knows about startTime and duration!
      if (context) {
        const startTime = context.store.startTime;
        if (startTime != null) {
          console.log(`Started at: ${new Date(startTime)}`);
        }
      }
    },
  }),
);

Multiple Plugins

When using multiple plugins, their stores are automatically merged:

const cli = await createCLI({
  name: "my-cli",
  plugins: [
    pluginA, // store: { foo: string }
    pluginB, // store: { bar: number }
    pluginC, // store: { baz: boolean }
  ] as const,
});

// In commands, the merged store type is available:
cli.command(
  defineCommand({
    handler: async ({ context }) => {
      // TypeScript knows about all store properties!
      context.store.foo; // string
      context.store.bar; // number
      context.store.baz; // boolean
    },
  }),
);

Module Augmentation

Plugins can extend core interfaces using TypeScript's module augmentation:

declare module "@bunli/core" {
  interface PluginStore {
    // Extend the shared plugin store
    myPluginData: string;
  }

  interface CommandContext {
    // Extend command execution context
    customField: number;
  }
}

Built-in Plugins

Bunli provides several built-in plugins:

@bunli/plugin-config

Loads and merges configuration from multiple sources:

import { configMergerPlugin } from "@bunli/plugin-config";

const cli = await createCLI({
  plugins: [
    configMergerPlugin({
      sources: ["~/.config/{{name}}/config.json", ".{{name}}rc.json"],
      mergeStrategy: "deep",
    }),
  ],
});

@bunli/plugin-ai-detect

Detects AI coding assistants from environment variables:

import { aiAgentPlugin } from "@bunli/plugin-ai-detect";

const cli = await createCLI({
  plugins: [aiAgentPlugin({ verbose: true })],
});

// In commands:
cli.command(
  defineCommand({
    name: "info",
    description: "Show AI agent detection info",
    handler: async ({ context }) => {
      if (context && context.store.isAIAgent) {
        console.log(`AI agents: ${context.store.aiAgents.join(", ")}`);
      }
    },
  }),
);

Best Practices

1. Use Type-Safe Stores

Always define explicit types for your plugin stores:

// ✅ Good - Explicit types with createPlugin generics
interface MyStore {
  items: string[];
  count: number;
}

// For direct plugins:
const plugin = createPlugin<MyStore>({
  name: "my-plugin",
  store: { items: [], count: 0 },
});

// For plugin factories:
const plugin = createPlugin<Options, MyStore>((options) => ({
  name: "my-plugin",
  store: { items: [], count: 0 },
}));

// ❌ Avoid - Less type safety
const plugin = createPlugin({
  store: { items: [], count: 0 }, // Types are inferred but less explicit
});

2. Handle Errors Gracefully

Error handling behavior varies by hook. The framework catches thrown errors via try/catch:

HookOn ErrorRecommendation
setupThrows, stops CLI initValidate inputs; don't throw for recoverable errors
beforeCommandThrows, stops command executionThrow on validation failure
preRunThrows, stops handlerUse for command-level guards only
configResolvedLogs, continuesSafe for telemetry and reporting
postRunLogs, continuesSafe for cleanup and metrics
afterCommandLogs, continuesSafe for reporting and cleanup
beforeCommand(context) {
  // Throwing here stops command execution
  if (!isValid(context)) {
    throw new Error('Validation failed')
  }
}

configResolved(config) {
  // Errors here are logged but don't stop execution — safe for telemetry:
  reportConfig({ plugins: config.plugins?.length ?? 0 })
}

3. Use Plugin Context

Leverage the plugin context for shared functionality:

setup(context) {
  // Use the logger
  context.logger.info('Plugin loaded')

  // Access paths
  console.log(`Config dir: ${context.paths.config}`)

  // Share data between plugins (setup store is a Map, unlike command-phase typed store)
  context.store.set('shared-key', 'value')
}

4. Document Plugin Options

Provide clear TypeScript interfaces for plugin options:

export interface MyPluginOptions {
  /**
   * Enable verbose logging
   * @default false
   */
  verbose?: boolean;

  /**
   * Custom timeout in milliseconds
   * @default 5000
   */
  timeout?: number;
}

Plugin Loading

Plugins can be loaded in various ways:

import { createCLI } from "@bunli/core";
import { createPlugin } from "@bunli/core/plugin";

const myPlugin = createPlugin({
  name: "my-plugin",
});

const myPluginFactory = createPlugin<{ verbose?: boolean }, {}>((options) => ({
  name: "my-plugin",
  version: options.verbose ? "debug" : undefined,
}));

const cli = await createCLI({
  plugins: [
    // Plugin object
    myPlugin,

    // Plugin factory
    myPluginFactory({ verbose: true }),
  ] as const,
});

Examples

Analytics Plugin

Track command usage:

interface AnalyticsStore {
  commandCount: number;
  commandHistory: string[];
}

const analyticsPlugin = createPlugin<AnalyticsStore>({
  name: "analytics",

  store: {
    commandCount: 0,
    commandHistory: [],
  },

  beforeCommand(context) {
    context.store.commandCount++;
    context.store.commandHistory = [...context.store.commandHistory, context.command];
  },

  afterCommand(context) {
    if (context.store.commandCount % 10 === 0) {
      console.log(`You've run ${context.store.commandCount} commands!`);
    }
  },
});

Environment Plugin

Add environment-specific behavior:

interface EnvStore {
  isDevelopment: boolean;
  isProduction: boolean;
}

const envPlugin = createPlugin<EnvStore>({
  name: "env-plugin",

  store: {
    isDevelopment: process.env.NODE_ENV === "development",
    isProduction: process.env.NODE_ENV === "production",
  },

  setup(context) {
    if (process.env.NODE_ENV === "development") {
      context.updateConfig({
        description: "Development mode",
      });
    }
  },
});

Generated Types and Plugins

When using type generation, plugin context is included in the generated types:

// Generated in .bunli/commands.gen.ts
import { cli } from "./.bunli/commands.gen";

// Generated types include plugin context
interface CommandContext<TStore> {
  store: TStore;
  // Plugin-specific context is included
}

// Access generated CLI API
const commands = cli.list();
const deploy = cli.findByName("deploy");

// Type-safe plugin store access
function usePluginData(context: CommandContext<any>) {
  // Access plugin store with full type safety
  const timingData = context.store.timing;
  const configData = context.store.config;
  const aiData = context.store.aiAgent;
}

This enables:

  • Plugin context types with full type safety
  • Store access for all registered plugins
  • Type-safe plugin data in command handlers
  • IntelliSense for plugin-specific functionality

Generated types work seamlessly with plugins, providing type safety for plugin store access and context usage.

Next Steps

On this page