Your First Plugin
Build a complete “Hello World” plugin from scratch. This tutorial covers the fundamentals of plugin development with detailed explanations.
What You’ll Build
A plugin that demonstrates:
- ✅ Plugin initialization and lifecycle
- ✅ Logging and debugging
- ✅ Command registration
- ✅ UI notifications
- ✅ Proper error handling
- ✅ Unit testing
Time to complete: 15 minutes
Prerequisites
Before starting:
- Node.js 18+ installed
- Lokus installed
- Basic TypeScript knowledge
- Code editor ready (VS Code recommended)
Step 1: Create the Plugin Structure
Create project directory
mkdir hello-world-plugin
cd hello-world-pluginInitialize npm
npm init -yInstall dependencies
npm install --save-dev lokus-plugin-sdk typescript esbuildStep 2: Create the Manifest
Create plugin.json:
{
"manifest": "2.0",
"id": "hello-world-plugin",
"name": "hello-world-plugin",
"displayName": "Hello World Plugin",
"version": "1.0.0",
"description": "A simple Hello World plugin for Lokus",
"author": "Your Name",
"license": "MIT",
"main": "./dist/index.js",
"lokusVersion": ">=1.0.0",
"permissions": [
"commands:register",
"ui:notifications"
],
"activationEvents": [
"onStartup"
]
}Manifest Explained
| Field | Value | Why? |
|---|---|---|
id | hello-world-plugin | Unique identifier (must be lowercase with hyphens) |
main | ./dist/index.js | Entry point after compilation |
permissions | commands:register, ui:notifications | APIs we’ll use |
activationEvents | onStartup | Load when Lokus starts |
Important: The id must match the folder name when you link the plugin. Use lowercase and hyphens only.
Step 3: Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}Step 4: Configure Build
Create esbuild.config.js:
const esbuild = require('esbuild');
esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/index.js',
platform: 'node',
target: 'es2020',
format: 'esm',
sourcemap: true,
external: ['lokus-plugin-sdk'],
}).catch(() => process.exit(1));Why These Settings?
bundle: true- Bundles dependencies into one fileplatform: 'node'- Node.js environmentformat: 'esm'- ES modules (import/export)external: ['lokus-plugin-sdk']- Don’t bundle the SDK (Lokus provides it)
Step 5: Write the Plugin Code
Create src/index.ts:
import { PluginContext } from 'lokus-plugin-sdk';
export default class HelloWorldPlugin {
// Store the plugin context
private context: PluginContext;
private logger: PluginContext['logger'];
/**
* Constructor - Called when Lokus loads the plugin
* This is synchronous - no async operations here!
*/
constructor(context: PluginContext) {
this.context = context;
this.logger = context.logger;
// Log initialization
this.logger.info('HelloWorldPlugin initialized');
this.logger.debug('Plugin ID:', context.pluginId);
this.logger.debug('Plugin path:', context.pluginPath);
}
/**
* Activate - Called after constructor
* Register commands, UI elements, etc.
*/
async activate(activationContext: any): Promise<void> {
this.logger.info('Activating HelloWorldPlugin...');
try {
// Register a simple command
activationContext.commands.registerCommand(
'helloWorld.sayHello',
{
name: 'Hello World: Say Hello',
description: 'Shows a friendly hello message',
callback: () => {
this.sayHello();
}
}
);
// Register a command with user input
activationContext.commands.registerCommand(
'helloWorld.greetUser',
{
name: 'Hello World: Greet User',
description: 'Greets a specific user by name',
callback: async () => {
await this.greetUser();
}
}
);
this.logger.info('HelloWorldPlugin activated successfully');
// Show startup notification
this.context.ui.showInformationMessage(
'Hello World plugin is ready!'
);
} catch (error) {
this.logger.error('Failed to activate plugin:', error);
throw error;
}
}
/**
* Deactivate - Called when plugin is disabled
* Clean up resources here
*/
async deactivate(): Promise<void> {
this.logger.info('Deactivating HelloWorldPlugin...');
// Cleanup code goes here (if needed)
this.logger.info('HelloWorldPlugin deactivated');
}
/**
* Command handler: Say Hello
*/
private sayHello(): void {
this.logger.info('Say Hello command executed');
this.context.ui.showInformationMessage(
'Hello from Hello World Plugin! 👋'
);
}
/**
* Command handler: Greet User
*/
private async greetUser(): Promise<void> {
this.logger.info('Greet User command executed');
// In a real implementation, you'd show an input dialog
// For now, we'll just show a message
const userName = 'Developer'; // Placeholder
this.context.ui.showInformationMessage(
`Hello, ${userName}! Welcome to Lokus! 🎉`
);
this.logger.debug(`Greeted user: $\\{userName\\}`);
}
}Code Explained
Constructor
Called when Lokus loads the plugin. Keep it fast and synchronous.
constructor(context: PluginContext) {
this.context = context;
this.logger = context.logger;
this.logger.info('Plugin initialized');
}- Store the
contextfor later use - Initialize logger
- Don’t register commands here (use
activate)
Step 6: Add Build Scripts
Update package.json:
{
"name": "hello-world-plugin",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "node esbuild.config.js",
"dev": "node esbuild.config.js --watch"
},
"devDependencies": {
"esbuild": "^0.19.0",
"lokus-plugin-sdk": "^1.0.0",
"typescript": "^5.0.0"
}
}Step 7: Build the Plugin
npm run buildExpected output:
dist/index.js 2.1kb
dist/index.js.map 3.4kbYou should now have a dist/index.js file. This is what Lokus will load!
Step 8: Link for Development
npx lokus-plugin linkOr manually create the symlink:
ln -s "$(pwd)" ~/.lokus/plugins/hello-world-pluginVerify:
ls -la ~/.lokus/plugins/
# Should show: hello-world-plugin -> /path/to/your/pluginStep 9: Test in Lokus
Start Lokus
- Quit Lokus completely (Cmd/Ctrl + Q)
- Start Lokus
Check Developer Console
Open Developer Tools (View → Developer → Toggle Developer Tools):
[hello-world-plugin] HelloWorldPlugin initialized
[hello-world-plugin] Activating HelloWorldPlugin...
[hello-world-plugin] HelloWorldPlugin activated successfullyYou should also see the startup notification: “Hello World plugin is ready!”
Test Commands
Open Command Palette
Press Cmd/Ctrl + Shift + P
Type “Hello World”
You should see two commands:
- Hello World: Say Hello
- Hello World: Greet User
Run “Say Hello”
You should see: “Hello from Hello World Plugin! 👋“
Check the logs
In Developer Console:
[hello-world-plugin] Say Hello command executed🎉 It works! You’ve built a complete plugin from scratch!
Understanding the Plugin Lifecycle
Lifecycle Methods
| Method | When Called | Purpose |
|---|---|---|
constructor() | Plugin loaded | Initialize state, store context |
activate() | After constructor | Register commands, UI, start services |
deactivate() | Plugin disabled | Cleanup, stop services, dispose resources |
Adding Error Handling
Let’s improve our plugin with better error handling:
private sayHello(): void {
try {
this.logger.info('Say Hello command executed');
this.context.ui.showInformationMessage(
'Hello from Hello World Plugin! 👋'
);
} catch (error) {
this.logger.error('Error in sayHello:', error);
this.context.ui.showErrorMessage(
'Oops! Something went wrong. Check the console for details.'
);
}
}Best practices:
- ✅ Wrap command handlers in try/catch
- ✅ Log errors with
logger.error() - ✅ Show user-friendly error messages
- ✅ Don’t let errors crash the plugin
Writing Tests
Create test/index.test.ts:
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import HelloWorldPlugin from '../src/index';
// Mock the plugin context
const createMockContext = () => ({
pluginId: 'hello-world-plugin',
pluginPath: '/mock/path',
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
ui: {
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
});
// Mock activation context
const createMockActivationContext = () => ({
commands: {
registerCommand: vi.fn(),
},
});
describe('HelloWorldPlugin', () => {
let plugin: HelloWorldPlugin;
let mockContext: any;
let mockActivationContext: any;
beforeEach(() => {
mockContext = createMockContext();
mockActivationContext = createMockActivationContext();
plugin = new HelloWorldPlugin(mockContext);
});
afterEach(async () => {
await plugin.deactivate();
});
it('should initialize with context', () => {
expect(plugin).toBeDefined();
expect(mockContext.logger.info).toHaveBeenCalledWith(
'HelloWorldPlugin initialized'
);
});
it('should activate and register commands', async () => {
await plugin.activate(mockActivationContext);
// Should register 2 commands
expect(mockActivationContext.commands.registerCommand).toHaveBeenCalledTimes(2);
// Should show startup notification
expect(mockContext.ui.showInformationMessage).toHaveBeenCalledWith(
'Hello World plugin is ready!'
);
});
it('should execute sayHello command', async () => {
await plugin.activate(mockActivationContext);
// Get the registered callback
const [, options] = mockActivationContext.commands.registerCommand.mock.calls[0];
// Execute the command
options.callback();
// Should show hello message
expect(mockContext.ui.showInformationMessage).toHaveBeenCalledWith(
'Hello from Hello World Plugin! 👋'
);
});
it('should log on deactivation', async () => {
await plugin.deactivate();
expect(mockContext.logger.info).toHaveBeenCalledWith(
'Deactivating HelloWorldPlugin...'
);
expect(mockContext.logger.info).toHaveBeenCalledWith(
'HelloWorldPlugin deactivated'
);
});
});Install test dependencies
npm install --save-dev vitest @types/nodeAdd test script
Update package.json:
{
"scripts": {
"build": "node esbuild.config.js",
"test": "vitest run",
"test:watch": "vitest"
}
}Run tests
npm testExpected output:
✓ test/index.test.ts (4 tests) 45ms
✓ HelloWorldPlugin
✓ should initialize with context
✓ should activate and register commands
✓ should execute sayHello command
✓ should log on deactivation
Test Files 1 passed (1)
Tests 4 passed (4)Common Pitfalls
Avoid These Mistakes
-
Async constructor: Never use
asyncin constructor// ❌ Wrong async constructor(context) { } // ✅ Correct constructor(context) { } async activate(ctx) { } -
Forgetting to rebuild: Always rebuild after changes
npm run build -
Not restarting Lokus: Changes require a full restart
-
Missing permissions: Add permissions to
plugin.json"permissions": ["commands:register"]
Next Steps
You’ve built a complete plugin! Here’s what to explore next:
Continue Learning
- Adding Commands - Learn about keyboard shortcuts, command categories, and editor integration
- UI Components - Create custom panels, status bar items, and progress indicators
Dive Deeper
- API Reference - Complete API docs
- Editor Plugins - Build editor extensions
- Publishing - Share your plugin
Complete Code
Full project structure
hello-world-plugin/
├── src/
│ └── index.ts
├── test/
│ └── index.test.ts
├── dist/
│ ├── index.js
│ └── index.js.map
├── plugin.json
├── package.json
├── tsconfig.json
├── esbuild.config.js
└── README.mdSummary
You learned how to:
- ✅ Create a plugin from scratch
- ✅ Set up TypeScript and build tools
- ✅ Write the plugin manifest
- ✅ Implement constructor, activate, and deactivate
- ✅ Register commands
- ✅ Use logging and notifications
- ✅ Handle errors properly
- ✅ Write unit tests
- ✅ Build and test in Lokus
Time completed: 15 minutes ⏱️
Ready to add more features? Continue to Adding Commands!