Getting Started

This guide will walk you through creating your first Lokus plugin from scratch. By the end, you’ll have a working plugin that adds a custom command and UI panel to Lokus.

Prerequisites

Before you begin, ensure you have:

  • Node.js 18+ installed
  • npm or yarn package manager
  • Lokus installed on your system
  • Basic knowledge of TypeScript and JavaScript
  • A code editor (VS Code recommended)

Quick Start

The fastest way to create a new plugin is using the Lokus Plugin CLI:

# Install the CLI globally
npm install -g @lokus/plugin-cli
 
# Create a new plugin
lokus-plugin create my-first-plugin
 
# Navigate to the plugin directory
cd my-first-plugin
 
# Start development mode
lokus-plugin dev

This creates a fully functional plugin with:

  • TypeScript configuration
  • Plugin manifest
  • Example code
  • Test setup
  • Hot reload support

Project Structure

Let’s explore the generated project structure:

my-first-plugin/
├── src/
│   ├── index.ts          # Plugin entry point
│   ├── commands.ts       # Command definitions
│   └── panels.ts         # UI panel definitions
├── assets/
│   ├── icon.png          # Plugin icon
│   └── logo.svg          # Logo assets
├── test/
│   └── index.test.ts     # Unit tests
├── package.json          # npm package configuration
├── plugin.json           # Plugin manifest
├── tsconfig.json         # TypeScript configuration
├── README.md             # Plugin documentation
└── .gitignore           # Git ignore rules

Understanding the Manifest

The plugin.json file is the heart of your plugin. It defines metadata, permissions, and contribution points:

{
  "id": "my-first-plugin",
  "version": "0.1.0",
  "name": "My First Plugin",
  "description": "A simple example plugin for Lokus",
  "author": "Your Name",
  "license": "MIT",
  "lokusVersion": "^1.0.0",
 
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
 
  "activationEvents": [
    "onStartup"
  ],
 
  "permissions": [
    "editor:read",
    "ui:create",
    "commands:register"
  ],
 
  "contributes": {
    "commands": [
      {
        "command": "myFirstPlugin.hello",
        "title": "Say Hello",
        "category": "My Plugin"
      }
    ]
  }
}

Creating Your First Plugin

Let’s build a simple plugin step by step.

Step 1: Define the Plugin Class

Create or edit src/index.ts:

import { Plugin, PluginContext } from '@lokus/plugin-sdk'
 
export default class MyFirstPlugin implements Plugin {
  private context?: PluginContext
 
  async activate(context: PluginContext) {
    this.context = context
    console.log('My First Plugin is now active!')
 
    // Register a command
    this.registerCommands()
 
    // Create a UI panel
    this.createPanel()
  }
 
  async deactivate() {
    console.log('My First Plugin is deactivated')
    // Cleanup code here
  }
 
  private registerCommands() {
    if (!this.context) return
 
    // Register "Say Hello" command
    this.context.subscriptions.push(
      this.context.api.commands.register({
        id: 'myFirstPlugin.hello',
        title: 'Say Hello',
        handler: () => this.sayHello()
      })
    )
  }
 
  private sayHello() {
    if (!this.context) return
 
    this.context.api.ui.showNotification(
      'Hello from My First Plugin!',
      'success'
    )
  }
 
  private createPanel() {
    if (!this.context) return
 
    this.context.subscriptions.push(
      this.context.api.ui.registerPanel({
        id: 'myFirstPlugin.panel',
        title: 'My First Panel',
        type: 'webview',
        location: 'sidebar',
        icon: 'star',
        html: this.getPanelHTML()
      })
    )
  }
 
  private getPanelHTML(): string {
    return `
      <!DOCTYPE html>
      <html>
        <head>
          <style>
            body {
              padding: 20px;
              font-family: system-ui, -apple-system, sans-serif;
            }
            h1 {
              color: #007acc;
              margin-bottom: 20px;
            }
            .button {
              background: #007acc;
              color: white;
              border: none;
              padding: 10px 20px;
              border-radius: 4px;
              cursor: pointer;
              font-size: 14px;
            }
            .button:hover {
              background: #005a9e;
            }
          </style>
        </head>
        <body>
          <h1>Welcome!</h1>
          <p>This is your first Lokus plugin panel.</p>
          <button class="button" onclick="handleClick()">
            Click Me
          </button>
 
          <script>
            function handleClick() {
              // Send message to plugin host
              window.parent.postMessage({
                command: 'buttonClicked'
              }, '*')
            }
          </script>
        </body>
      </html>
    `
  }
}

Step 2: Add Package Dependencies

Edit package.json:

{
  "name": "my-first-plugin",
  "version": "0.1.0",
  "description": "My first Lokus plugin",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "lokus-plugin dev",
    "test": "jest",
    "lint": "eslint src --ext .ts",
    "package": "lokus-plugin package"
  },
  "dependencies": {
    "@lokus/plugin-sdk": "^1.0.0"
  },
  "devDependencies": {
    "@types/node": "^18.0.0",
    "typescript": "^5.0.0",
    "eslint": "^8.0.0",
    "@typescript-eslint/eslint-plugin": "^5.0.0",
    "@typescript-eslint/parser": "^5.0.0",
    "jest": "^29.0.0",
    "@types/jest": "^29.0.0",
    "ts-jest": "^29.0.0"
  },
  "lokus": {
    "manifest": "./plugin.json"
  }
}

Step 3: Configure TypeScript

Create or edit tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "test"]
}

Step 4: Build and Test

# Install dependencies
npm install
 
# Build the plugin
npm run build
 
# Run in development mode with hot reload
npm run dev

Testing Your Plugin

Manual Testing

  1. Start Development Mode:

    lokus-plugin dev
  2. Open Lokus - The plugin should be automatically loaded

  3. Test the Command:

    • Open command palette (Ctrl/Cmd + Shift + P)
    • Type “Say Hello”
    • Execute the command
    • You should see a notification
  4. Test the Panel:

    • Look for “My First Panel” in the sidebar
    • Click to open it
    • Interact with the button

Automated Testing

Create test/index.test.ts:

import { describe, it, expect, beforeEach } from '@jest/globals'
import { createMockContext, TestHelper } from '@lokus/plugin-sdk/testing'
import MyFirstPlugin from '../src/index'
 
describe('MyFirstPlugin', () => {
  let plugin: MyFirstPlugin
  let context: ReturnType<typeof createMockContext>
  let helper: TestHelper
 
  beforeEach(() => {
    context = createMockContext()
    helper = new TestHelper(context)
    plugin = new MyFirstPlugin()
  })
 
  it('should activate successfully', async () => {
    await plugin.activate(context)
    expect(context.subscriptions.length).toBeGreaterThan(0)
  })
 
  it('should register hello command', async () => {
    await plugin.activate(context)
 
    const commands = await helper.getRegisteredCommands()
    expect(commands).toContain('myFirstPlugin.hello')
  })
 
  it('should create panel', async () => {
    await plugin.activate(context)
 
    const panels = await helper.getRegisteredPanels()
    expect(panels).toContain('myFirstPlugin.panel')
  })
 
  it('should show notification when command executed', async () => {
    await plugin.activate(context)
 
    await context.api.commands.execute('myFirstPlugin.hello')
 
    const notifications = helper.getNotifications()
    expect(notifications.length).toBe(1)
    expect(notifications[0].message).toBe('Hello from My First Plugin!')
    expect(notifications[0].type).toBe('success')
  })
 
  it('should deactivate cleanly', async () => {
    await plugin.activate(context)
    await plugin.deactivate()
 
    // Verify cleanup
    expect(context.subscriptions.every(s => s.disposed)).toBe(true)
  })
})

Run tests:

npm test

Adding More Features

Adding a Second Command

private registerCommands() {
  if (!this.context) return
 
  // Existing hello command
  this.context.subscriptions.push(
    this.context.api.commands.register({
      id: 'myFirstPlugin.hello',
      title: 'Say Hello',
      handler: () => this.sayHello()
    })
  )
 
  // New command: Insert current date
  this.context.subscriptions.push(
    this.context.api.commands.register({
      id: 'myFirstPlugin.insertDate',
      title: 'Insert Current Date',
      handler: () => this.insertDate()
    })
  )
}
 
private async insertDate() {
  if (!this.context) return
 
  const date = new Date().toLocaleDateString()
  await this.context.api.editor.insertContent(date)
}

Don’t forget to add the command to your manifest:

{
  "contributes": {
    "commands": [
      {
        "command": "myFirstPlugin.hello",
        "title": "Say Hello",
        "category": "My Plugin"
      },
      {
        "command": "myFirstPlugin.insertDate",
        "title": "Insert Current Date",
        "category": "My Plugin"
      }
    ]
  }
}

Adding a Keyboard Shortcut

Add to your manifest:

{
  "contributes": {
    "keybindings": [
      {
        "command": "myFirstPlugin.insertDate",
        "key": "ctrl+shift+d",
        "mac": "cmd+shift+d",
        "when": "editorTextFocus"
      }
    ]
  }
}

Adding Configuration

Add to your manifest:

{
  "contributes": {
    "configuration": {
      "title": "My First Plugin",
      "properties": {
        "myFirstPlugin.greeting": {
          "type": "string",
          "default": "Hello",
          "description": "Greeting message to display"
        },
        "myFirstPlugin.showTimestamp": {
          "type": "boolean",
          "default": true,
          "description": "Show timestamp with greeting"
        }
      }
    }
  }
}

Use configuration in your code:

private async sayHello() {
  if (!this.context) return
 
  const greeting = await this.context.api.config.get<string>(
    'myFirstPlugin.greeting',
    'Hello'
  )
 
  const showTimestamp = await this.context.api.config.get<boolean>(
    'myFirstPlugin.showTimestamp',
    true
  )
 
  let message = `${greeting} from My First Plugin!`
 
  if (showTimestamp) {
    const time = new Date().toLocaleTimeString()
    message += ` (${time})`
  }
 
  this.context.api.ui.showNotification(message, 'success')
}

Debugging

Enable Debug Mode

Edit plugin.json:

{
  "dev": {
    "hotReload": true,
    "debug": true,
    "sourceMaps": true,
    "verboseLogging": true
  }
}

Using Debug Logs

export default class MyFirstPlugin implements Plugin {
  async activate(context: PluginContext) {
    // Debug logging
    context.api.log('debug', 'Plugin activating...')
 
    try {
      this.registerCommands()
      context.api.log('info', 'Commands registered successfully')
    } catch (error) {
      context.api.log('error', 'Failed to register commands', error)
    }
  }
}

Using Breakpoints

  1. Open Lokus in development mode
  2. Open Developer Tools (View → Developer → Toggle Developer Tools)
  3. Navigate to Sources tab
  4. Find your plugin code
  5. Set breakpoints
  6. Trigger plugin functionality

Packaging

When you’re ready to share your plugin:

# Package the plugin
lokus-plugin package
 
# This creates: my-first-plugin-0.1.0.vsix

The .vsix file can be:

  • Installed locally in Lokus
  • Shared with others
  • Published to the plugin registry

Common Patterns

Lazy Loading

Load heavy dependencies only when needed:

private async processMarkdown() {
  // Lazy load markdown-it
  const { default: MarkdownIt } = await import('markdown-it')
  const md = new MarkdownIt()
  return md.render(content)
}

Error Handling

Always handle errors gracefully:

private async safeExecute<T>(
  operation: () => Promise<T>,
  fallback: T
): Promise<T> {
  try {
    return await operation()
  } catch (error) {
    this.context?.api.log('error', 'Operation failed', error)
    this.context?.api.ui.showNotification(
      'Operation failed. Please try again.',
      'error'
    )
    return fallback
  }
}

Resource Cleanup

Always clean up resources in deactivate:

export default class MyFirstPlugin implements Plugin {
  private timers: NodeJS.Timeout[] = []
  private watchers: Disposable[] = []
 
  async activate(context: PluginContext) {
    // Create resources
    this.timers.push(
      setInterval(() => this.checkUpdates(), 60000)
    )
 
    const watcher = context.api.fs.watch('/path', () => {})
    this.watchers.push(watcher)
  }
 
  async deactivate() {
    // Clean up timers
    this.timers.forEach(timer => clearInterval(timer))
    this.timers = []
 
    // Dispose watchers
    this.watchers.forEach(watcher => watcher.dispose())
    this.watchers = []
  }
}

Troubleshooting

Plugin Won’t Activate

Check:

  • Manifest file is valid JSON
  • Required permissions are declared
  • Activation events are properly configured
  • No syntax errors in code

Solution:

# Validate plugin
lokus-plugin validate
 
# Check logs
lokus-plugin dev --verbose

Hot Reload Not Working

Check:

  • Development mode is enabled
  • File watcher is running
  • No compilation errors

Solution:

# Restart dev mode
lokus-plugin dev --clean

Commands Not Appearing

Check:

  • Commands declared in manifest
  • Command IDs match between manifest and code
  • Command registration in activate method

Solution: Verify manifest and code match:

// plugin.json
{
  "contributes": {
    "commands": [
      { "command": "myPlugin.hello", "title": "Say Hello" }
    ]
  }
}
// index.ts
api.commands.register({
  id: 'myPlugin.hello',  // Must match manifest
  title: 'Say Hello',
  handler: () => {}
})

Next Steps

Now that you have a working plugin, explore more advanced topics:

Related Documentation:

Additional Resources