Bunli
Core Concepts

Plugins

Extend Bunli's functionality with a powerful plugin system

Plugins

Bunli's plugin system provides a powerful way to extend your CLI's functionality with reusable components. The plugin system is designed with type safety at its core, ensuring that plugin data flows seamlessly through your application with full TypeScript support.

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++ // TypeScript knows the type!
  }
})

Plugin with Options

For configurable plugins, use a factory function:

import { createPlugin } from '@bunli/core/plugin'

interface MyPluginOptions {
  prefix: string
  verbose?: boolean
}

const myPlugin = createPlugin((options: MyPluginOptions) => ({
  name: 'my-plugin',
  
  store: {
    messages: [] as string[]
  },
  
  beforeCommand({ store }) {
    const message = `${options.prefix}: Command starting`
    store.messages.push(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',
    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(', ')}`)
  
  // Access and modify store
  context.store.lastCommand = context.command
}

4. After Command

The afterCommand hook runs after command execution:

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

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({ store }) {
    store.startTime = Date.now() // Type-safe!
  },
  
  afterCommand({ store }) {
    if (store.startTime) {
      store.duration = Date.now() - store.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',
  handler: async ({ context }) => {
    // TypeScript knows about startTime and duration!
    if (context?.store.startTime) {
      console.log(`Started at: ${new Date(context.store.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/plugin' {
  interface EnvironmentInfo {
    isDocker: boolean
    containerName?: string
  }
}

const dockerPlugin = createPlugin({
  name: 'docker-detect',
  
  beforeCommand({ env }) {
    env.isDocker = !!process.env.DOCKER_CONTAINER
    env.containerName = process.env.HOSTNAME
  }
})

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({
  handler: async ({ context }) => {
    if (context?.env.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

Wrap plugin operations in try-catch blocks:

beforeCommand(context) {
  try {
    // Plugin logic
  } catch (error) {
    context.logger.warn(`Plugin error: ${error.message}`)
    // Don't throw - let the command continue
  }
}

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
  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:

const cli = await createCLI({
  plugins: [
    // Plugin object
    myPlugin,
    
    // Plugin factory
    myPlugin({ verbose: true }),
    
    // Path to plugin file
    './plugins/custom.js',
    
    // Plugin with options as tuple
    [myPlugin, { verbose: true }]
  ]
})

Examples

Analytics Plugin

Track command usage:

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

const analyticsPlugin = createPlugin<AnalyticsStore>({
  name: 'analytics',
  
  store: {
    commandCount: 0,
    commandHistory: []
  },
  
  beforeCommand({ store, command }) {
    store.commandCount++
    store.commandHistory.push(command)
  },
  
  afterCommand({ store }) {
    if (store.commandCount % 10 === 0) {
      console.log(`🎉 You've run ${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 (context.store.isDevelopment) {
      context.updateConfig({
        // Enable debug features in development
        debug: true
      })
    }
  }
})

Next Steps