Creating Plugins
Plugins extend CLI functionality with lifecycle hooks. Use them for analytics, telemetry, logging, or modifying CLI structure at runtime.
Basic Structure
typescript
import { definePlugin } from 'cheloni';
export interface MyPluginConfig {
level?: 'info' | 'debug';
}
const myPlugin = definePlugin((options: MyPluginConfig = {}) => ({
name: 'my-plugin',
onInit: async ({ cli, plugin }) => {
// Called when CLI is created
},
onBeforeCommandExecution: async ({ cli, plugin, command }) => {
if (options.level === 'debug') {
console.debug('About to run', command.name);
}
},
onAfterCommandExecution: async ({ cli, plugin, command }) => {
// Called after each command execution (even if it fails)
},
onDestroy: async ({ cli, plugin }) => {
// Called when CLI is shutting down
},
}));Lifecycle Hooks
onInit
Runs once when the CLI is created, before any commands execute. Use it to:
- Modify CLI structure (add commands, options)
- Initialize services
- Set up configuration
typescript
import { createOption, defineOption } from 'cheloni';
import z from 'zod';
const plugin = definePlugin({
name: 'my-plugin',
onInit: async ({ cli }) => {
console.log('CLI initialized');
},
});onBeforeCommand
Runs before each command handler. Use it for:
- Authentication checks
- Logging command start
- Performance tracking
typescript
onBeforeCommand: async ({ cli, command }) => {
console.log(`Executing: ${command.manifest.name}`);
const startTime = Date.now();
// Store in plugin state for onAfterCommand
}onAfterCommand
Runs after each command handler, even if it throws. Use it for:
- Cleanup
- Logging completion
- Error tracking
typescript
onAfterCommand: async ({ cli, command }) => {
console.log(`Completed: ${command.manifest.name}`);
// Always runs, even if handler failed
}onDestroy
Runs when the CLI is shutting down. Use it for:
- Closing connections
- Flushing logs
- Cleanup
typescript
onDestroy: async ({ cli }) => {
await flushLogs();
await closeDatabase();
}Usage
Global Plugins
Apply to all commands:
typescript
import { createCli, definePlugin } from 'cheloni';
const analyticsPlugin = definePlugin({
name: 'analytics',
onBeforeCommand: async ({ command }) => {
trackCommandUsage(command.manifest.name);
},
});
const cli = await createCli({
name: 'my-cli',
plugins: [analyticsPlugin],
command: rootCommand,
});Command-Specific Plugins
Apply only to specific commands:
typescript
import { defineCommand, definePlugin } from 'cheloni';
const deploymentPlugin = definePlugin({
name: 'deployment-plugin',
onBeforeCommand: async () => {
await checkDeploymentPermissions();
},
});
const deployCommand = defineCommand({
name: 'deploy',
plugins: [deploymentPlugin],
handler: async ({ options }) => {
// ...
},
});Practical Examples
Analytics Plugin
typescript
const analyticsPlugin = definePlugin({
name: 'analytics',
onBeforeCommand: async ({ command }) => {
await trackEvent('command_started', {
command: command.manifest.name,
timestamp: Date.now(),
});
},
onAfterCommand: async ({ command }) => {
await trackEvent('command_completed', {
command: command.manifest.name,
});
},
});Logging Plugin
typescript
const loggingPlugin = definePlugin({
name: 'logging',
onBeforeCommand: async ({ command }) => {
console.log(`[${new Date().toISOString()}] Starting: ${command.manifest.name}`);
},
onAfterCommand: async ({ command }) => {
console.log(`[${new Date().toISOString()}] Completed: ${command.manifest.name}`);
},
});CLI Modification Plugin
typescript
import { createCommand, defineCommand } from 'cheloni';
const customHelpPlugin = definePlugin({
name: 'custom-help',
onInit: async ({ cli }) => {
if (!cli.command) return;
// Add a custom subcommand to the root command
const customHelpCommand = defineCommand({
name: 'custom-help',
handler: async () => {
console.log('Custom help text');
},
});
const existingDef = cli.command.definition;
const existingCommands = existingDef.commands ?? [];
cli.command = createCommand({
...existingDef,
commands: [...existingCommands, customHelpCommand],
});
},
});Example
typescript
import { definePlugin, defineCommand, createCli } from 'cheloni';
const timer = definePlugin({
name: 'timer',
onBeforeCommandExecution: async ({ command }) => {
console.time(command.name);
},
onAfterCommandExecution: async ({ command }) => {
console.timeEnd(command.name);
},
});
// Global — will run for every command
const cli = await createCli({
name: 'my-cli',
plugins: [timer],
command: rootCommand,
});
// Per-command — will run only for this command
defineCommand({
name: 'deploy',
plugins: [timer],
handler: async () => { /* ... */ },
});Error Handling
Hook Error Behavior
onInit: Errors prevent CLI initialization and are thrown immediatelyonBeforeCommand: Errors prevent command execution and are displayed to the useronAfterCommand: Errors are logged but don't override the original handler erroronDestroy: Errors are logged during shutdown
typescript
const plugin = definePlugin({
name: 'my-plugin',
onBeforeCommand: async ({ command }) => {
if (!hasPermission(command)) {
throw new Error(`Permission denied for command: ${command.manifest.name}`);
}
},
onAfterCommand: async ({ command }) => {
try {
await logCommandExecution(command);
} catch (error) {
// Log but don't throw - original error takes precedence
console.error('Failed to log execution:', error);
}
},
});Key points:
- Throw errors in
onInitandonBeforeCommandto stop execution - Don't throw in
onAfterCommandoronDestroy- handle errors internally - Use try-catch in cleanup hooks to prevent masking original errors
- Error messages are automatically displayed by the framework
Best Practices
- Keep hooks focused: Each hook should do one thing well
- Handle errors gracefully: Hook failures can break CLI initialization or execution
- Use
onAfterCommandfor cleanup: It always runs, even if the handler throws - Store state in closures: Use closures to share data between hooks
- Make plugins reusable: Export plugins for use across multiple CLIs
- Use
onInitfor structural changes - Modify CLI structure only inonInit - Keep hooks focused - Each hook should do one thing well
- Handle errors gracefully -
onAfterCommandExecutionandonDestroyshould not throw - Use command plugins for command-specific behavior - Global plugins for cross-cutting concerns
- Avoid side effects in
onBeforeCommandExecution- Use it for validation/checks, not mutations