This guide explains how to create custom plugins for the Claude Code statusline.
The statusline uses a dynamic plugin loading system. Plugins are automatically loaded from the config.json file - no manual registration required!
Each plugin is autonomous and follows this structure:
src/plugins/my-plugin/
├── index.ts # Plugin implementation (must use export default)
├── default.json # Default configuration
└── README.md # Plugin documentation
Key concepts:
- 🚀 Dynamic loading: Plugins are loaded on-demand via
import() - 📦 Export default: All plugins must use
export default - ⚙️ Auto-discovery: Just add plugin name to config and it loads automatically
- ❌ Error handling: Failed plugins show
❌ plugin-namein statusline
mkdir -p src/plugins/my-plugin{
"name": "my-plugin",
"prefix": "🔧",
"color": "cyan",
"options": {
"myOption": true
}
}Important: Do NOT include enabled or order fields. These are managed by the root configuration.
IMPORTANT: Use export default for the plugin object!
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Plugin, PluginContext, PluginConfig, PluginResult } from '../../types/plugin.js';
import { colors } from '../../lib/constant.js';
import { deepMerge } from '../../lib/merge.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface MyPluginOptions {
myOption?: boolean;
}
// Load default config
const defaultConfig: PluginConfig = JSON.parse(
readFileSync(join(__dirname, 'default.json'), 'utf-8')
);
// Use export default (required for dynamic loading)
export default {
name: 'my-plugin',
execute(context: PluginContext, userConfig: PluginConfig): PluginResult {
try {
// IMPORTANT: Use deepMerge instead of spread operator!
// deepMerge properly merges nested objects like 'options'
const config = deepMerge(defaultConfig, userConfig);
// Use values from merged config (no hardcoded defaults!)
const prefix = config.prefix;
const color = config.color;
const colorCode = colors[color as keyof typeof colors] || colors.cyan;
// Plugin-specific options
const options = config.options as MyPluginOptions | undefined;
const myOption = options?.myOption;
// Your plugin logic here
const content = `${colorCode}${prefix} My Content${colors.reset}`;
return { content };
} catch (error) {
return {
content: '',
error: error instanceof Error ? error.message : 'Unknown error in my-plugin',
};
}
},
} as Plugin;No registration needed! Simply add your plugin to config.json:
{
"plugins": [
{
"name": "my-plugin"
}
]
}The plugin will be automatically loaded when the statusline starts! ✨
How it works:
- PluginManager reads
config.json - Sees
"my-plugin"in the list - Dynamically imports from
src/plugins/my-plugin/index.js - Validates the plugin structure
- Registers and executes it
The order in the array determines display order.
interface Plugin {
name: string;
execute(context: PluginContext, config: PluginConfig): Promise<PluginResult> | PluginResult;
}Provides information about the current Claude Code session:
interface PluginContext {
input: ClaudeCodeInput; // Claude Code input data
currentDir: string; // Current directory path
dirName: string; // Current directory name
}User-provided configuration for the plugin:
interface PluginConfig {
name: string;
prefix?: string;
color?: string;
options?: PluginOptions; // Plugin-specific options
}The result returned by your plugin:
interface PluginResult {
content: string; // Display text (can be empty)
error?: string; // Optional error message
}Plugins are autonomous and handle their own configuration merging:
- Default config is loaded from
default.json - User config is passed via
execute(context, userConfig) - Deep merge happens inside the plugin:
deepMerge(defaultConfig, userConfig)
deepMerge instead of spread operator!
The spread operator ({ ...defaultConfig, ...userConfig }) only does a shallow merge, which means nested objects like options will be completely replaced instead of merged.
❌ Wrong (shallow merge):
const config = { ...defaultConfig, ...userConfig };
// If userConfig.options exists, it REPLACES defaultConfig.options entirely!✅ Correct (deep merge):
import { deepMerge } from '../../lib/merge.js';
const config = deepMerge(defaultConfig, userConfig);
// Nested objects are properly merged recursivelyExample:
// Plugin's default.json
{
"name": "my-plugin",
"prefix": "🔧",
"color": "cyan",
"options": {
"myOption": true,
"anotherOption": "default"
}
}
// User's config.json (root)
{
"plugins": [
{
"name": "my-plugin",
"prefix": "⚙️",
"options": {
"myOption": false
}
}
]
}
// With deepMerge (✅ correct result)
{
"name": "my-plugin",
"prefix": "⚙️", // overridden
"color": "cyan", // kept from default
"options": {
"myOption": false, // overridden
"anotherOption": "default" // kept from default!
}
}
// With spread operator (❌ wrong result)
{
"name": "my-plugin",
"prefix": "⚙️", // overridden
"color": "cyan", // kept from default
"options": {
"myOption": false // only this!
// anotherOption is LOST! ❌
}
}try {
// Your logic
return { content: '...' };
} catch (error) {
return {
content: '',
error: error instanceof Error ? error.message : 'Unknown error',
};
}If the plugin has nothing to display, return empty content:
if (!data) {
return { content: '' };
}deepMerge to merge configurations, never the spread operator!
❌ Bad (shallow merge):
const config = { ...defaultConfig, ...userConfig }; // NO! Breaks nested options✅ Good (deep merge):
import { deepMerge } from '../../lib/merge.js';
const config = deepMerge(defaultConfig, userConfig); // YES! Properly merges nested objectsWhy? The spread operator only does a shallow merge. If userConfig.options exists, it will completely replace defaultConfig.options, losing all default option values that weren't overridden.
IMPORTANT: All default values must be in default.json, not in code!
❌ Bad (hardcoded defaults):
const prefix = config.prefix || '🔧'; // NO!
const color = config.color || 'cyan'; // NO!
const myOption = options?.myOption ?? true; // NO!✅ Good (defaults from default.json):
// Deep merge happens at the top of execute()
const config = deepMerge(defaultConfig, userConfig);
// Then just use the values directly
const prefix = config.prefix;
const color = config.color;
const myOption = options?.myOption;After the merge, all values are guaranteed to exist from defaultConfig (loaded from default.json). Only use fallbacks for type safety (e.g., colors[color as keyof typeof colors] || colors.cyan).
Use the colors constant for consistent styling:
import { colors } from '../../lib/constant.js';
const content = `${colors.cyan}${icon} Text${colors.reset}`;Available colors: reset, bright, dim, cyan, blue, green, yellow, magenta, gray, red
- Avoid expensive operations in
execute() - Cache results when possible
- Use
stdio: ['pipe', 'pipe', 'ignore']when calling child processes
Create a README.md in your plugin directory documenting:
- What the plugin does
- Configuration options
- Examples
- User Config Load: User's
config.jsonis loaded - Dynamic Loading: PluginManager dynamically imports each plugin listed in config:
- Reads plugin name from config array
- Calls
import(\../plugins/${name}/index.js`)` - Validates plugin structure (must have
nameandexecute) - Registers plugin in internal Map
- On error: stores error and displays
❌ plugin-namein statusline
- Execution: For each registered plugin (in order):
- Plugin receives
contextanduserConfig - Plugin loads its
default.json(default config) - Plugin merges configs:
deepMerge(defaultConfig, userConfig) - Plugin executes its logic
- Plugin returns
PluginResult
- Plugin receives
- Assembly: Results are joined with separator
Key advantage: Only plugins in the config are loaded - no wasted memory or imports!
See existing plugins for reference:
- directory - Simple plugin with one option
- git - Plugin with git command execution
- node-version - Simple plugin reading process info
- claude-tokens - Complex plugin with file parsing