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) {
    const count = context.getStoreValue('count')
    context.setStoreValue('count', count + 1)
  }
})

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`
    const messages = context.getStoreValue('messages')
    context.setStoreValue('messages', [...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. 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(context) {
    context.setStoreValue('startTime', Date.now())
  },
  
  afterCommand(context) {
    const startTime = context.getStoreValue('startTime')
    if (startTime != null) {
      context.setStoreValue('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.getStoreValue('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/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({
  name: 'info',
  description: 'Show AI agent detection info',
  handler: async ({ context }) => {
    if (context && context.getStoreValue('isAIAgent')) {
      console.log(`AI agents: ${context.getStoreValue('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:

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) {
    const commandCount = context.getStoreValue('commandCount')
    context.setStoreValue('commandCount', commandCount + 1)

    const commandHistory = context.getStoreValue('commandHistory')
    context.setStoreValue('commandHistory', [...commandHistory, context.command])
  },
  
  afterCommand(context) {
    const commandCount = context.getStoreValue('commandCount')
    if (commandCount % 10 === 0) {
      console.log(`You've run ${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 { getCommandApi, listCommands } from './commands.gen'

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

// Access plugin store in commands
const infoApi = getCommandApi('info')
console.log(infoApi.plugins) // Available plugin data

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