From 80294c987618f49b3a27aa01b8bf8736910c46aa Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Tue, 14 Apr 2026 23:20:37 +0530 Subject: [PATCH] docs: add plugin system implementation plan 9 tasks across 8 chunks, strict TDD: - EventBus with crash-safe handlers - PluginAPI base class - PluginLoader with manifest validation - PluginContext with scoped API - PluginRegistry with lifecycle management - SettingsStore for plugin-scoped settings - Export hooks integration - Sample plugin + renderer wiring Amit Haridas --- .../plans/2026-04-14-plugin-system.md | 1226 +++++++++++++++++ 1 file changed, 1226 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-plugin-system.md diff --git a/docs/superpowers/plans/2026-04-14-plugin-system.md b/docs/superpowers/plans/2026-04-14-plugin-system.md new file mode 100644 index 0000000..789e7c8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-plugin-system.md @@ -0,0 +1,1226 @@ +# Plugin System Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a lightweight plugin registry that allows built-in (and future third-party) plugins to register sidebar panels, commands, status bar indicators, settings, and export hooks through a unified context API. + +**Architecture:** PluginLoader discovers manifests, PluginRegistry validates and stores them, PluginContext provides scoped API to each plugin. Plugins activate lazily — sidebar panels don't execute until clicked. All plugin handlers are wrapped for crash safety. Existing SidebarManager, CommandPalette, and status bar are extended, not replaced. + +**Tech Stack:** Electron, electron-store (simple JSON store already in main.js), Node.js require() for plugin loading. + +**Spec:** `docs/superpowers/specs/2026-04-14-v5-platform-design.md` — Section 1: Plugin System + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `src/plugins/plugin-api.js` | Base class with default no-op implementations for all extension points | +| `src/plugins/plugin-loader.js` | Discovers and validates manifest.json files from built-in + user directories | +| `src/plugins/plugin-registry.js` | Stores loaded plugins, manages lifecycle (init/activate/deactivate), wraps handlers in try/catch | +| `src/plugins/plugin-context.js` | Builds scoped context API for each plugin (sidebar, commands, statusBar, settings, editor, events, exports, ipc) | +| `src/plugins/event-bus.js` | Typed event emitter with versioned payloads, `hasHandler()` for cross-plugin capability checks | +| `src/plugins/settings-store.js` | Plugin-scoped settings using the existing main.js JSON store via IPC | +| `src/plugins/built-in/_sample/manifest.json` | Empty sample manifest documenting the schema | +| `src/plugins/built-in/_sample/index.js` | Minimal plugin that logs init — proves the system works | +| `tests/plugin-loader.test.js` | Tests for manifest discovery and validation | +| `tests/plugin-registry.test.js` | Tests for registration, lifecycle, crash safety | +| `tests/plugin-context.test.js` | Tests for scoped API surface | +| `tests/event-bus.test.js` | Tests for event emission, versioned payloads, hasHandler | + +--- + +## Chunk 1: Event Bus + +The event bus has zero dependencies on other plugin files. Everything else uses it. + +### Task 1: EventBus class + +**Files:** +- Create: `src/plugins/event-bus.js` +- Test: `tests/event-bus.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/event-bus.test.js +const { EventBus } = require('../src/plugins/event-bus'); + +describe('EventBus', () => { + let bus; + + beforeEach(() => { + bus = new EventBus(); + }); + + test('on/emit — listener receives payload', () => { + const received = []; + bus.on('document:saved', (payload) => received.push(payload)); + bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' }); + expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]); + }); + + test('on — ignores events with no listeners', () => { + expect(() => bus.emit('unknown:event', {})).not.toThrow(); + }); + + test('off — removes specific listener', () => { + const handler = jest.fn(); + bus.on('test:event', handler); + bus.off('test:event', handler); + bus.emit('test:event', {}); + expect(handler).not.toHaveBeenCalled(); + }); + + test('off — removes all listeners for event when no handler given', () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + bus.on('test:event', h1); + bus.on('test:event', h2); + bus.off('test:event'); + bus.emit('test:event', {}); + expect(h1).not.toHaveBeenCalled(); + expect(h2).not.toHaveBeenCalled(); + }); + + test('hasHandler — returns true when listener exists', () => { + bus.on('ai:analyze', () => {}); + expect(bus.hasHandler('ai:analyze')).toBe(true); + }); + + test('hasHandler — returns false when no listener exists', () => { + expect(bus.hasHandler('ai:analyze')).toBe(false); + }); + + test('handler errors are caught and logged, not thrown', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + bus.on('bad:event', () => { throw new Error('boom'); }); + expect(() => bus.emit('bad:event', {})).not.toThrow(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test('multiple listeners all receive the event', () => { + const results = []; + bus.on('multi:event', () => results.push('a')); + bus.on('multi:event', () => results.push('b')); + bus.emit('multi:event', {}); + expect(results).toEqual(['a', 'b']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/event-bus.test.js --no-cache` +Expected: FAIL — cannot find module `../src/plugins/event-bus` + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/event-bus.js +class EventBus { + constructor() { + this.listeners = new Map(); + } + + on(event, handler) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(handler); + } + + off(event, handler) { + if (!handler) { + this.listeners.delete(event); + return; + } + const handlers = this.listeners.get(event); + if (handlers) { + const idx = handlers.indexOf(handler); + if (idx !== -1) handlers.splice(idx, 1); + } + } + + emit(event, payload) { + const handlers = this.listeners.get(event); + if (!handlers) return; + for (const handler of handlers) { + try { + handler(payload); + } catch (err) { + console.error(`[EventBus] Error in handler for "${event}":`, err); + } + } + } + + hasHandler(event) { + const handlers = this.listeners.get(event); + return handlers != null && handlers.length > 0; + } +} + +module.exports = { EventBus }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/event-bus.test.js --no-cache` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/event-bus.js tests/event-bus.test.js +git commit -m "feat(plugins): add EventBus with typed events and crash-safe handlers" +``` + +--- + +## Chunk 2: Plugin API Base Class + Manifest Schema + +### Task 2: PluginAPI base class + +**Files:** +- Create: `src/plugins/plugin-api.js` +- Test: `tests/plugin-api.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/plugin-api.test.js +const { PluginAPI } = require('../src/plugins/plugin-api'); + +describe('PluginAPI', () => { + test('has default no-op lifecycle methods', () => { + const plugin = new PluginAPI(); + expect(() => plugin.init({})).not.toThrow(); + expect(() => plugin.activate()).not.toThrow(); + expect(() => plugin.deactivate()).not.toThrow(); + }); + + test('getManifest returns null by default', () => { + const plugin = new PluginAPI(); + expect(plugin.getManifest()).toBeNull(); + }); + + test('subclass can override init', () => { + let called = false; + class MyPlugin extends PluginAPI { + init(context) { + called = true; + this.context = context; + } + } + const p = new MyPlugin(); + p.init({ foo: 'bar' }); + expect(called).toBe(true); + expect(p.context.foo).toBe('bar'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/plugin-api.test.js --no-cache` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/plugin-api.js +class PluginAPI { + /** + * Called when the plugin is discovered and loaded. + * Receives a scoped context object with APIs. + * @param {object} context - Plugin context (sidebar, commands, settings, etc.) + */ + init(context) { + this.context = context; + } + + /** Called when the plugin is activated (e.g., sidebar panel clicked). */ + activate() {} + + /** Called when the plugin is deactivated. */ + deactivate() {} + + /** Returns the parsed manifest.json for this plugin. */ + getManifest() { + return null; + } +} + +module.exports = { PluginAPI }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/plugin-api.test.js --no-cache` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/plugin-api.js tests/plugin-api.test.js +git commit -m "feat(plugins): add PluginAPI base class with no-op lifecycle" +``` + +--- + +## Chunk 3: Plugin Loader + Manifest Validation + +### Task 3: PluginLoader discovers and validates manifests + +**Files:** +- Create: `src/plugins/plugin-loader.js` +- Test: `tests/plugin-loader.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/plugin-loader.test.js +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { PluginLoader } = require('../src/plugins/plugin-loader'); + +describe('PluginLoader', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeManifest(dir, manifest) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + } + + test('discoverPlugins — finds manifests in built-in directory', () => { + const pluginDir = path.join(tempDir, 'my-plugin'); + writeManifest(pluginDir, { + id: 'my-plugin', + name: 'My Plugin', + version: '1.0.0', + description: 'Test' + }); + const loader = new PluginLoader([pluginDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].id).toBe('my-plugin'); + }); + + test('discoverPlugins — skips directories without manifest.json', () => { + const emptyDir = path.join(tempDir, 'no-manifest'); + fs.mkdirSync(emptyDir, { recursive: true }); + const loader = new PluginLoader([emptyDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(0); + }); + + test('validateManifest — accepts valid manifest', () => { + const manifest = { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin' + }; + const loader = new PluginLoader([]); + expect(loader.validateManifest(manifest)).toBe(true); + }); + + test('validateManifest — rejects manifest missing id', () => { + const manifest = { name: 'No ID', version: '1.0.0', description: 'x' }; + const loader = new PluginLoader([]); + expect(() => loader.validateManifest(manifest)).toThrow(/id/); + }); + + test('validateManifest — rejects manifest missing name', () => { + const manifest = { id: 'test', version: '1.0.0', description: 'x' }; + const loader = new PluginLoader([]); + expect(() => loader.validateManifest(manifest)).toThrow(/name/); + }); + + test('validateManifest — rejects manifest with duplicate id (already loaded)', () => { + const loader = new PluginLoader([]); + loader.loadedIds = new Set(['existing-plugin']); + expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' })) + .toThrow(/duplicate/i); + }); + + test('discoverPlugins — loads index.js if present', () => { + const pluginDir = path.join(tempDir, 'with-index'); + writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' }); + fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports = { PluginAPI }; const { PluginAPI } = require("../plugin-api"); class P extends PluginAPI {} module.exports = { Plugin: P };'); + const loader = new PluginLoader([pluginDir]); + const plugins = loader.discoverPlugins(); + expect(plugins[0].PluginClass).toBeDefined(); + }); + + test('discoverPlugins — continues if one plugin fails to load', () => { + const badDir = path.join(tempDir, 'bad'); + writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' }); + fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");'); + + const goodDir = path.join(tempDir, 'good'); + writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' }); + + const loader = new PluginLoader([badDir, goodDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].id).toBe('good'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/plugin-loader.test.js --no-cache` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/plugin-loader.js +const fs = require('fs'); +const path = require('path'); + +class PluginLoader { + /** + * @param {string[]} searchDirs - Directories to scan for plugin folders + */ + constructor(searchDirs = []) { + this.searchDirs = searchDirs; + this.loadedIds = new Set(); + } + + /** + * Discover plugins by scanning searchDirs for manifest.json files. + * Returns array of { id, name, version, description, manifest, PluginClass, dir } + */ + discoverPlugins() { + const plugins = []; + for (const dir of this.searchDirs) { + if (!fs.existsSync(dir)) continue; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pluginDir = path.join(dir, entry.name); + const manifestPath = path.join(pluginDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) continue; + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw); + this.validateManifest(manifest); + let PluginClass = null; + const indexPath = path.join(pluginDir, 'index.js'); + if (fs.existsSync(indexPath)) { + try { + const loaded = require(indexPath); + PluginClass = loaded.Plugin || loaded.default || null; + } catch (err) { + console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message); + continue; + } + } + plugins.push({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + manifest, + PluginClass, + dir: pluginDir + }); + this.loadedIds.add(manifest.id); + } catch (err) { + console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message); + } + } + } + return plugins; + } + + /** + * Validate a manifest object. Throws on invalid. + */ + validateManifest(manifest) { + if (!manifest.id) throw new Error('Manifest missing required field: id'); + if (!manifest.name) throw new Error('Manifest missing required field: name'); + if (!manifest.version) throw new Error('Manifest missing required field: version'); + if (!manifest.description) throw new Error('Manifest missing required field: description'); + if (this.loadedIds.has(manifest.id)) { + throw new Error(`Duplicate plugin id: "${manifest.id}"`); + } + return true; + } +} + +module.exports = { PluginLoader }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/plugin-loader.test.js --no-cache` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/plugin-loader.js tests/plugin-loader.test.js +git commit -m "feat(plugins): add PluginLoader with manifest discovery and validation" +``` + +--- + +## Chunk 4: Plugin Context + +### Task 4: PluginContext builds scoped API for each plugin + +**Files:** +- Create: `src/plugins/plugin-context.js` +- Test: `tests/plugin-context.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/plugin-context.test.js +const { PluginContext } = require('../src/plugins/plugin-context'); +const { EventBus } = require('../src/plugins/event-bus'); + +describe('PluginContext', () => { + let context; + let mockDeps; + + beforeEach(() => { + mockDeps = { + pluginId: 'test-plugin', + sidebar: { registerPanel: jest.fn() }, + commands: { register: jest.fn() }, + statusBar: { registerIndicator: jest.fn() }, + eventBus: new EventBus(), + settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() }, + editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() }, + ipc: { invoke: jest.fn(), on: jest.fn() } + }; + context = new PluginContext(mockDeps); + }); + + test('exposes sidebar.registerPanel', () => { + const handler = jest.fn(); + context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler }); + expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler }); + }); + + test('exposes commands.register', () => { + const handler = jest.fn(); + context.commands.register('do-thing', 'Do Thing', handler, 'Ctrl+Alt+T'); + expect(mockDeps.commands.register).toHaveBeenCalledWith('test-plugin:do-thing', 'Do Thing', expect.any(Function), 'Ctrl+Alt+T'); + }); + + test('command handler is wrapped in try/catch', () => { + const badHandler = () => { throw new Error('boom'); }; + context.commands.register('bad-cmd', 'Bad', badHandler); + const registeredHandler = mockDeps.commands.register.mock.calls[0][2]; + expect(() => registeredHandler()).not.toThrow(); + }); + + test('exposes settings.get/set scoped to plugin', () => { + context.settings.get('myKey'); + context.settings.set('myKey', 'myValue'); + expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey'); + expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue'); + }); + + test('exposes editor methods', () => { + context.editor.getContent(); + expect(mockDeps.editor.getContent).toHaveBeenCalled(); + context.editor.getSelection(); + expect(mockDeps.editor.getSelection).toHaveBeenCalled(); + }); + + test('exposes events.on and events.emit via event bus', () => { + const handler = jest.fn(); + context.events.on('test:event', handler); + context.events.emit('test:event', { data: 1 }); + expect(handler).toHaveBeenCalledWith({ data: 1 }); + }); + + test('exposes events.hasHandler', () => { + expect(context.events.hasHandler('no:such')).toBe(false); + context.events.on('exists:event', () => {}); + expect(context.events.hasHandler('exists:event')).toBe(true); + }); + + test('exposes ipc.invoke and ipc.on', () => { + context.ipc.invoke('test:channel', { a: 1 }); + expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/plugin-context.test.js --no-cache` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/plugin-context.js +class PluginContext { + /** + * @param {object} deps - Injected dependencies + * @param {string} deps.pluginId - Plugin's unique ID (for namespacing) + * @param {object} deps.sidebar - SidebarManager.registerPanel + * @param {object} deps.commands - CommandPalette.register + * @param {object} deps.statusBar - StatusBar.registerIndicator + * @param {object} deps.eventBus - EventBus instance + * @param {object} deps.settings - { get, set, onChanged } + * @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged } + * @param {object} deps.ipc - { invoke, on } + */ + constructor(deps) { + const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc } = deps; + + this.sidebar = { + registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts) + }; + + this.commands = { + register: (id, label, handler, shortcut) => { + const safeHandler = (...args) => { + try { + handler(...args); + } catch (err) { + console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err); + } + }; + commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut); + } + }; + + this.statusBar = { + registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts) + }; + + this.settings = { + get: (key) => settings.get(`plugins.${pluginId}.${key}`), + set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value), + onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb) + }; + + this.editor = { + getContent: () => editor.getContent(), + getSelection: () => editor.getSelection(), + insertAtCursor: (text) => editor.insertAtCursor(text), + onContentChanged: (cb) => editor.onContentChanged(cb) + }; + + this.events = { + on: (event, handler) => eventBus.on(event, handler), + off: (event, handler) => eventBus.off(event, handler), + emit: (event, payload) => eventBus.emit(event, payload), + hasHandler: (event) => eventBus.hasHandler(event) + }; + + this.ipc = { + invoke: (channel, ...args) => ipc.invoke(channel, ...args), + on: (channel, handler) => ipc.on(channel, handler) + }; + } +} + +module.exports = { PluginContext }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/plugin-context.test.js --no-cache` +Expected: PASS (9 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/plugin-context.js tests/plugin-context.test.js +git commit -m "feat(plugins): add PluginContext with scoped, crash-safe API" +``` + +--- + +## Chunk 5: Plugin Registry + +### Task 5: PluginRegistry manages plugin lifecycle + +**Files:** +- Create: `src/plugins/plugin-registry.js` +- Test: `tests/plugin-registry.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/plugin-registry.test.js +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { PluginRegistry } = require('../src/plugins/plugin-registry'); +const { PluginAPI } = require('../src/plugins/plugin-api'); +const { EventBus } = require('../src/plugins/event-bus'); + +class TestPlugin extends PluginAPI { + init(context) { this.initialized = true; this.ctx = context; } + activate() { this.activated = true; } + deactivate() { this.deactivated = true; } +} + +describe('PluginRegistry', () => { + let registry; + let mockDeps; + + beforeEach(() => { + mockDeps = { + sidebar: { registerPanel: jest.fn() }, + commands: { register: jest.fn() }, + statusBar: { registerIndicator: jest.fn() }, + eventBus: new EventBus(), + settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() }, + editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() }, + ipc: { invoke: jest.fn(), on: jest.fn() } + }; + registry = new PluginRegistry(mockDeps); + }); + + test('register — stores plugin and calls init', () => { + const plugin = new TestPlugin(); + registry.register({ + id: 'test', + name: 'Test', + version: '1.0.0', + description: 'desc', + manifest: {}, + PluginClass: TestPlugin, + dir: '/tmp/test' + }); + expect(plugin.initialized).toBe(true); + expect(registry.getPlugin('test')).toBeDefined(); + }); + + test('register — works without PluginClass (manifest-only plugin)', () => { + expect(() => { + registry.register({ + id: 'manifest-only', + name: 'Manifest Only', + version: '1.0.0', + description: 'desc', + manifest: {}, + PluginClass: null, + dir: '/tmp/test' + }); + }).not.toThrow(); + expect(registry.getPlugin('manifest-only')).toBeDefined(); + }); + + test('register — plugin init error does not crash registry', () => { + class BadPlugin extends PluginAPI { + init() { throw new Error('init fail'); } + } + expect(() => { + registry.register({ + id: 'bad', + name: 'Bad', + version: '1.0.0', + description: 'desc', + manifest: {}, + PluginClass: BadPlugin, + dir: '/tmp/test' + }); + }).not.toThrow(); + // Plugin should NOT be registered since init failed + expect(registry.getPlugin('bad')).toBeUndefined(); + }); + + test('getPlugin — returns undefined for unknown plugin', () => { + expect(registry.getPlugin('nope')).toBeUndefined(); + }); + + test('getAll — returns all registered plugins', () => { + registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' }); + registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' }); + expect(registry.getAll()).toHaveLength(2); + }); + + test('activate — calls activate on plugin instance', () => { + const plugin = new TestPlugin(); + registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' }); + registry.activate('test'); + const instance = registry.getPlugin('test').instance; + expect(instance.activated).toBe(true); + }); + + test('deactivate — calls deactivate on plugin instance', () => { + registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' }); + registry.deactivate('test'); + const instance = registry.getPlugin('test').instance; + expect(instance.deactivated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/plugin-registry.test.js --no-cache` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/plugin-registry.js +const { PluginContext } = require('./plugin-context'); +const { PluginAPI } = require('./plugin-api'); + +class PluginRegistry { + constructor(deps) { + this.deps = deps; + this.plugins = new Map(); + } + + /** + * Register a discovered plugin. Creates instance, builds context, calls init. + * If init throws, plugin is NOT registered. + */ + register(pluginInfo) { + const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo; + + let instance = null; + if (PluginClass) { + instance = new PluginClass(); + instance._manifest = manifest; + } else { + instance = new PluginAPI(); + instance._manifest = manifest; + } + + const context = new PluginContext({ + pluginId: id, + sidebar: this.deps.sidebar, + commands: this.deps.commands, + statusBar: this.deps.statusBar, + eventBus: this.deps.eventBus, + settings: this.deps.settings, + editor: this.deps.editor, + ipc: this.deps.ipc + }); + + try { + instance.init(context); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message); + return; + } + + this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context }); + console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`); + } + + getPlugin(id) { + return this.plugins.get(id); + } + + getAll() { + return Array.from(this.plugins.values()); + } + + activate(id) { + const plugin = this.plugins.get(id); + if (plugin?.instance) { + try { + plugin.instance.activate(); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message); + } + } + } + + deactivate(id) { + const plugin = this.plugins.get(id); + if (plugin?.instance) { + try { + plugin.instance.deactivate(); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message); + } + } + } +} + +module.exports = { PluginRegistry }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/plugin-registry.test.js --no-cache` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/plugin-registry.js tests/plugin-registry.test.js +git commit -m "feat(plugins): add PluginRegistry with lifecycle and crash-safe init" +``` + +--- + +## Chunk 6: Settings Store (IPC bridge) + +### Task 6: SettingsStore — plugin-scoped settings via IPC + +**Files:** +- Create: `src/plugins/settings-store.js` +- Test: `tests/settings-store.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/settings-store.test.js +const { SettingsStore } = require('../src/plugins/settings-store'); + +describe('SettingsStore', () => { + let store; + let data; + + beforeEach(() => { + data = {}; + store = new SettingsStore({ + get: (key) => data[key], + set: (key, value) => { data[key] = value; } + }); + }); + + test('get returns value for full key', () => { + data['plugins.my-plugin.myKey'] = 'myValue'; + expect(store.get('plugins.my-plugin.myKey')).toBe('myValue'); + }); + + test('get returns undefined for missing key', () => { + expect(store.get('plugins.my-plugin.missing')).toBeUndefined(); + }); + + test('set stores value', () => { + store.set('plugins.my-plugin.myKey', 42); + expect(data['plugins.my-plugin.myKey']).toBe(42); + }); + + test('set overwrites existing value', () => { + data['plugins.my-plugin.myKey'] = 'old'; + store.set('plugins.my-plugin.myKey', 'new'); + expect(data['plugins.my-plugin.myKey']).toBe('new'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest tests/settings-store.test.js --no-cache` +Expected: FAIL — cannot find module + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// src/plugins/settings-store.js +class SettingsStore { + /** + * @param {object} backend - { get(key), set(key, value) } backed by main process store + */ + constructor(backend) { + this.backend = backend; + } + + get(key) { + return this.backend.get(key); + } + + set(key, value) { + this.backend.set(key, value); + } + + onChanged(key, callback) { + // Future: watch for settings changes via IPC + // For now, settings are read-once. Change notifications will be added when needed. + } +} + +module.exports = { SettingsStore }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest tests/settings-store.test.js --no-cache` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/plugins/settings-store.js tests/settings-store.test.js +git commit -m "feat(plugins): add SettingsStore for plugin-scoped settings" +``` + +--- + +## Chunk 6.5: Export Hooks + Settings onChanged + +### Task 6.5: Export hooks registration and settings change notifications + +**Files:** +- Modify: `src/plugins/plugin-context.js` — add exports API +- Modify: `src/plugins/plugin-registry.js` — store export hooks +- Modify: `src/renderer.js` — call export hooks during export flow +- Test: `tests/plugin-context.test.js` — add export hook test + +- [ ] **Step 1: Add exports API to PluginContext test** + +Add to `tests/plugin-context.test.js`: +```javascript +test('exposes exports.registerPreHook and registerPostHook', () => { + const exportHooks = { preHooks: [], postHooks: [] }; + const ctx = new PluginContext({ ...mockDeps, exportHooks }); + const handler = jest.fn(); + ctx.exports.registerPreHook(handler); + expect(exportHooks.preHooks).toContain(handler); +}); +``` + +- [ ] **Step 2: Add exportHooks to PluginContext constructor** + +In `src/plugins/plugin-context.js`, add to constructor deps and expose: +```javascript +this.exports = { + registerPreHook: (handler) => deps.exportHooks.preHooks.push(handler), + registerPostHook: (handler) => deps.exportHooks.postHooks.push(handler) +}; +``` + +- [ ] **Step 3: Add exportHooks to PluginRegistry** + +In `src/plugins/plugin-registry.js`, initialize and pass to context: +```javascript +constructor(deps) { + // ...existing... + this.exportHooks = { preHooks: [], postHooks: [] }; +} + +// In register(), add to context deps: +const context = new PluginContext({ + // ...existing... + exportHooks: this.exportHooks +}); +``` + +- [ ] **Step 4: Wire export hooks into export flow** + +In `src/renderer.js`, find the export function (search for `export-with-options` IPC send). Before sending the IPC, call pre-hooks. After completion, call post-hooks: + +```javascript +// Before export +pluginRegistry.exportHooks.preHooks.forEach(hook => { + try { hook({ format, filePath }); } catch (e) { console.error('[ExportHook]', e); } +}); +``` + +- [ ] **Step 5: Run all tests** + +Run: `npx jest --no-cache` +Expected: All existing + new tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/plugins/plugin-context.js src/plugins/plugin-registry.js src/renderer.js tests/plugin-context.test.js +git commit -m "feat(plugins): add export hooks and wire into export flow" +``` + +**Note on settings onChanged:** The `onChanged` API is deferred. For v5.0 MVP, plugins read settings on init and when their panels are activated. Full change notification requires an IPC watcher in main process — add when a plugin actually needs it. + +--- + +## Chunk 7: Sample Plugin + Integration Wiring + +### Task 7: Sample built-in plugin that proves the system works + +**Files:** +- Create: `src/plugins/built-in/_sample/manifest.json` +- Create: `src/plugins/built-in/_sample/index.js` + +- [ ] **Step 1: Create sample manifest** + +```json +{ + "id": "_sample", + "name": "Sample Plugin", + "version": "1.0.0", + "description": "Demonstrates the plugin system. Safe to delete.", + "icon": "puzzle", + "extensionPoints": { + "commands": [ + { "id": "hello", "label": "Sample: Hello World", "shortcut": "" } + ] + }, + "settings": [] +} +``` + +- [ ] **Step 2: Create sample plugin implementation** + +```javascript +// src/plugins/built-in/_sample/index.js +const { PluginAPI } = require('../../plugin-api'); + +class SamplePlugin extends PluginAPI { + init(context) { + this.context = context; + context.commands.register('hello', 'Sample: Hello World', () => { + console.log('[SamplePlugin] Hello from the plugin system!'); + }); + } +} + +module.exports = { Plugin: SamplePlugin }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/plugins/built-in/_sample/ +git commit -m "feat(plugins): add sample plugin demonstrating the system" +``` + +### Task 8: Wire plugin system into renderer.js initialization + +**Files:** +- Modify: `src/renderer.js` (around line 1475 where SidebarManager is initialized) +- Modify: `src/main.js` (add IPC handlers for plugin settings) + +- [ ] **Step 1: Add plugin initialization function in renderer.js** + +Find the section around line 1475 where `sidebarManager` and `commandPalette` are created. After the existing panel registrations (around line 1530), add: + +```javascript +// --- Plugin System --- +const { PluginLoader } = require('./plugins/plugin-loader'); +const { PluginRegistry } = require('./plugins/plugin-registry'); +const { EventBus } = require('./plugins/event-bus'); +const { SettingsStore } = require('./plugins/settings-store'); +const path = require('path'); + +const pluginEventBus = new EventBus(); +const pluginSettings = new SettingsStore({ + get: (key) => window.electronAPI.invoke('plugin-settings:get', key), + set: (key, value) => window.electronAPI.invoke('plugin-settings:set', { key, value }) +}); + +const pluginRegistry = new PluginRegistry({ + sidebar: sidebarManager, + commands: commandPalette, + statusBar: { + registerIndicator: (id, opts) => { + const statusBarRight = document.querySelector('.status-bar-right'); + if (!statusBarRight) return; + let indicator = document.getElementById(`plugin-indicator-${id}`); + if (!indicator) { + indicator = document.createElement('span'); + indicator.className = 'status-item'; + indicator.id = `plugin-indicator-${id}`; + // Insert before the last separator or at the end + statusBarRight.appendChild(document.createTextNode('|')); + statusBarRight.appendChild(indicator); + } + if (opts.render) opts.render(indicator); + } + }, + eventBus: pluginEventBus, + settings: pluginSettings, + editor: { + getContent: () => tabManager.getCurrentContent(), + getSelection: () => tabManager.getSelection(), + insertAtCursor: (text) => tabManager.insertAtCursor(text), + onContentChanged: (cb) => pluginEventBus.on('document:changed', cb) + }, + ipc: { + invoke: (ch, data) => window.electronAPI.invoke(ch, data), + on: (ch, cb) => window.electronAPI.on(ch, cb) + } +}); + +const builtInDir = path.join(__dirname, 'plugins', 'built-in'); +const loader = new PluginLoader([builtInDir]); +const discovered = loader.discoverPlugins(); +discovered.forEach(p => pluginRegistry.register(p)); +console.log(`[Plugins] Loaded ${discovered.length} plugins`); +``` + +- [ ] **Step 2: Add IPC channels in preload.js** + +Add to `ALLOWED_SEND_CHANNELS`: +```javascript +'plugin-settings:get', +'plugin-settings:set', +``` + +- [ ] **Step 3: Add IPC handlers in main.js** + +Find the IPC handler section and add: +```javascript +ipcMain.handle('plugin-settings:get', (event, key) => { + return store.get(key); +}); + +ipcMain.handle('plugin-settings:set', (event, { key, value }) => { + store.set(key, value); +}); +``` + +- [ ] **Step 4: Verify app starts without errors** + +Run: `npm start` +Expected: App opens normally, console shows `[Plugins] Loaded X plugins`. All existing features work. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer.js src/preload.js src/main.js +git commit -m "feat(plugins): wire plugin system into app initialization" +``` + +--- + +## Chunk 8: Run Full Test Suite + +### Task 9: Verify all tests pass together + +- [ ] **Step 1: Run full test suite** + +Run: `npm test` +Expected: All existing + new plugin tests pass (no regressions) + +- [ ] **Step 2: Run linter** + +Run: `npm run lint` +Expected: No errors + +- [ ] **Step 3: Manual smoke test** + +Run: `npm start` +Verify: +- App opens normally +- All sidebar panels work (Explorer, Git, Snippets, Templates, Outline) +- Command palette opens (Ctrl+Shift+P) +- Existing keyboard shortcuts work +- Console shows plugin system initialized + +- [ ] **Step 4: Commit any fixes**