openbot 0.2.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/agents/openbot/index.js +76 -0
  2. package/dist/agents/openbot/middleware/approval.js +132 -0
  3. package/dist/agents/openbot/runtime.js +289 -0
  4. package/dist/agents/openbot/system-prompt.js +32 -0
  5. package/dist/agents/openbot/tools/delegation.js +78 -0
  6. package/dist/agents/openbot/tools/mcp.js +99 -0
  7. package/dist/agents/openbot/tools/shell.js +91 -0
  8. package/dist/agents/openbot/tools/storage.js +75 -0
  9. package/dist/agents/openbot/tools/ui.js +176 -0
  10. package/dist/agents/system.js +20 -93
  11. package/dist/app/cli.js +1 -1
  12. package/dist/app/config.js +4 -1
  13. package/dist/app/server.js +15 -8
  14. package/dist/bus/agent-package.js +1 -0
  15. package/dist/bus/plugin.js +1 -0
  16. package/dist/bus/services.js +600 -0
  17. package/dist/bus/types.js +1 -0
  18. package/dist/harness/context.js +131 -0
  19. package/dist/harness/event-normalizer.js +59 -0
  20. package/dist/harness/orchestrator.js +27 -227
  21. package/dist/harness/process.js +25 -3
  22. package/dist/harness/queue-processor.js +227 -0
  23. package/dist/harness/runtime-factory.js +103 -0
  24. package/dist/plugins/ai-sdk/index.js +37 -0
  25. package/dist/plugins/ai-sdk/runtime.js +330 -0
  26. package/dist/plugins/ai-sdk/system-prompt.js +3 -0
  27. package/dist/plugins/ai-sdk.js +277 -87
  28. package/dist/plugins/approval/index.js +159 -0
  29. package/dist/plugins/approval.js +163 -0
  30. package/dist/plugins/delegation/index.js +79 -0
  31. package/dist/plugins/delegation.js +67 -11
  32. package/dist/plugins/mcp/index.js +108 -0
  33. package/dist/plugins/shell/index.js +99 -0
  34. package/dist/plugins/shell.js +123 -0
  35. package/dist/plugins/storage-tools/index.js +85 -0
  36. package/dist/plugins/storage.js +240 -5
  37. package/dist/plugins/ui/index.js +184 -0
  38. package/dist/plugins/ui.js +185 -21
  39. package/dist/registry/agents.js +138 -0
  40. package/dist/registry/plugins.js +91 -50
  41. package/dist/services/agent-packages.js +103 -0
  42. package/dist/services/plugins.js +98 -0
  43. package/dist/services/storage.js +360 -94
  44. package/docs/agents.md +39 -66
  45. package/docs/architecture.md +1 -1
  46. package/docs/plugins.md +70 -58
  47. package/docs/templates/AGENT.example.md +57 -0
  48. package/package.json +3 -2
  49. package/src/app/cli.ts +1 -1
  50. package/src/app/config.ts +14 -4
  51. package/src/app/server.ts +23 -10
  52. package/src/app/types.ts +385 -16
  53. package/src/assets/icon.svg +4 -1
  54. package/src/bus/plugin.ts +67 -0
  55. package/src/bus/services.ts +666 -0
  56. package/src/bus/types.ts +147 -0
  57. package/src/harness/context.ts +160 -0
  58. package/src/harness/event-normalizer.ts +82 -0
  59. package/src/harness/orchestrator.ts +35 -273
  60. package/src/harness/process.ts +28 -4
  61. package/src/harness/queue-processor.ts +309 -0
  62. package/src/harness/runtime-factory.ts +125 -0
  63. package/src/plugins/ai-sdk/index.ts +44 -0
  64. package/src/plugins/ai-sdk/runtime.ts +410 -0
  65. package/src/plugins/ai-sdk/system-prompt.ts +4 -0
  66. package/src/plugins/approval/index.ts +228 -0
  67. package/src/plugins/delegation/index.ts +94 -0
  68. package/src/plugins/mcp/index.ts +128 -0
  69. package/src/plugins/shell/index.ts +123 -0
  70. package/src/plugins/storage-tools/index.ts +101 -0
  71. package/src/plugins/ui/index.ts +227 -0
  72. package/src/registry/plugins.ts +106 -55
  73. package/src/services/plugins.ts +133 -0
  74. package/src/services/storage.ts +465 -137
  75. package/src/agents/system.ts +0 -112
  76. package/src/plugins/ai-sdk.ts +0 -197
  77. package/src/plugins/delegation.ts +0 -60
  78. package/src/plugins/mcp.ts +0 -154
  79. package/src/plugins/storage.ts +0 -725
  80. package/src/plugins/ui.ts +0 -57
@@ -0,0 +1,227 @@
1
+ import { MelonyPlugin } from 'melony';
2
+ import z from 'zod';
3
+ import type { Plugin } from '../../bus/plugin.js';
4
+ import {
5
+ OpenBotEvent,
6
+ OpenBotState,
7
+ RenderUIWidgetData,
8
+ UIWidgetField,
9
+ UIWidgetListItem,
10
+ UIWidgetSpec,
11
+ } from '../../app/types.js';
12
+
13
+ const actionSchema = z.object({
14
+ id: z.string().describe('Stable action ID returned by client:ui:widget:response.'),
15
+ label: z.string().describe('Human-readable button label.'),
16
+ value: z.unknown().optional().describe('Optional machine-readable value for this action.'),
17
+ variant: z.enum(['primary', 'secondary', 'danger']).optional(),
18
+ disabled: z.boolean().optional(),
19
+ });
20
+
21
+ const optionSchema = z.object({
22
+ label: z.string(),
23
+ value: z.string(),
24
+ });
25
+
26
+ const fieldSchema = z.object({
27
+ id: z.string().describe('Stable field ID used as the submitted value key.'),
28
+ label: z.string(),
29
+ type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
30
+ description: z.string().optional(),
31
+ placeholder: z.string().optional(),
32
+ required: z.boolean().optional(),
33
+ options: z.array(optionSchema).optional(),
34
+ defaultValue: z.unknown().optional(),
35
+ });
36
+
37
+ const listItemSchema = z.object({
38
+ id: z.string(),
39
+ label: z.string(),
40
+ description: z.string().optional(),
41
+ status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
42
+ metadata: z.record(z.string(), z.unknown()).optional(),
43
+ });
44
+
45
+ const widgetBaseSchema = {
46
+ widgetId: z.string().optional().describe('Stable widget ID. Defaults from toolCallId.'),
47
+ title: z.string().optional(),
48
+ description: z.string().optional(),
49
+ body: z.string().optional(),
50
+ state: z.enum(['open', 'submitted', 'cancelled', 'error']).optional(),
51
+ metadata: z.record(z.string(), z.unknown()).optional(),
52
+ };
53
+
54
+ const renderWidgetSchema = z.union([
55
+ z.object({
56
+ ...widgetBaseSchema,
57
+ kind: z.literal('message'),
58
+ actions: z.array(actionSchema).optional(),
59
+ }),
60
+ z.object({
61
+ ...widgetBaseSchema,
62
+ kind: z.literal('choice'),
63
+ actions: z.array(actionSchema).min(1),
64
+ }),
65
+ z.object({
66
+ ...widgetBaseSchema,
67
+ kind: z.literal('form'),
68
+ fields: z.array(fieldSchema).optional(),
69
+ submitLabel: z.string().optional(),
70
+ actions: z.array(actionSchema).optional(),
71
+ props: z.record(z.string(), z.unknown()).optional(),
72
+ }),
73
+ z.object({
74
+ ...widgetBaseSchema,
75
+ kind: z.literal('list'),
76
+ items: z.array(listItemSchema).optional(),
77
+ actions: z.array(actionSchema).optional(),
78
+ }),
79
+ z.object({
80
+ kind: z.enum(['approval', 'todo_list']).describe('Legacy preset. Prefer choice or list.'),
81
+ widgetId: z.string().optional(),
82
+ title: z.string().optional(),
83
+ props: z.record(z.string(), z.unknown()).optional(),
84
+ metadata: z.record(z.string(), z.unknown()).optional(),
85
+ }),
86
+ ]);
87
+
88
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
89
+ !!value && typeof value === 'object' && !Array.isArray(value);
90
+
91
+ const readString = (value: unknown): string | undefined =>
92
+ typeof value === 'string' && value.trim() ? value : undefined;
93
+
94
+ const asFields = (value: unknown): UIWidgetField[] | undefined =>
95
+ Array.isArray(value) ? (value as UIWidgetField[]) : undefined;
96
+
97
+ const asListItems = (value: unknown): UIWidgetListItem[] | undefined =>
98
+ Array.isArray(value) ? (value as UIWidgetListItem[]) : undefined;
99
+
100
+ const todoToListItem = (todo: unknown, index: number): UIWidgetListItem => {
101
+ if (!isRecord(todo)) {
102
+ return { id: `todo_${index + 1}`, label: String(todo) };
103
+ }
104
+ return {
105
+ id: readString(todo.id) || `todo_${index + 1}`,
106
+ label:
107
+ readString(todo.label) ||
108
+ readString(todo.task) ||
109
+ readString(todo.title) ||
110
+ `Todo ${index + 1}`,
111
+ description: readString(todo.description),
112
+ status: readString(todo.status) as UIWidgetListItem['status'],
113
+ metadata: todo,
114
+ };
115
+ };
116
+
117
+ const createWidgetId = (data: RenderUIWidgetData, toolCallId?: string): string => {
118
+ if ('widgetId' in data && data.widgetId) return data.widgetId;
119
+ if (toolCallId) return `widget_${toolCallId}`;
120
+ return `widget_${Date.now()}`;
121
+ };
122
+
123
+ const normalizeWidget = (
124
+ data: RenderUIWidgetData,
125
+ state: OpenBotState,
126
+ toolCallId?: string,
127
+ ): UIWidgetSpec => {
128
+ const widgetId = createWidgetId(data, toolCallId);
129
+
130
+ if (data.kind === 'approval') {
131
+ const props = data.props || {};
132
+ return {
133
+ widgetId,
134
+ kind: 'choice',
135
+ title: data.title || 'Approval Required',
136
+ body:
137
+ readString(props.message) ||
138
+ readString(props.summary) ||
139
+ 'Please approve or deny this action.',
140
+ metadata: {
141
+ ...(data.metadata || {}),
142
+ legacyKind: 'approval',
143
+ actionId: props.actionId,
144
+ },
145
+ actions: [
146
+ { id: 'approve', label: 'Approve', value: props.actionId || 'approve', variant: 'primary' },
147
+ { id: 'deny', label: 'Deny', value: props.actionId || 'deny', variant: 'danger' },
148
+ ],
149
+ };
150
+ }
151
+
152
+ if (data.kind === 'todo_list') {
153
+ const props = data.props || {};
154
+ const stateTodos = isRecord(state.threadDetails?.state)
155
+ ? (state.threadDetails.state as Record<string, unknown>).todos
156
+ : undefined;
157
+ const todos = asListItems(props.todos) || asListItems(stateTodos) || [];
158
+ return {
159
+ widgetId,
160
+ kind: 'list',
161
+ title: data.title || readString(props.title) || 'Task List',
162
+ description: readString(props.description),
163
+ metadata: { ...(data.metadata || {}), legacyKind: 'todo_list' },
164
+ items: todos.map(todoToListItem),
165
+ };
166
+ }
167
+
168
+ if (data.kind === 'form') {
169
+ const propsSource = (data as unknown as { props?: unknown }).props;
170
+ const props = isRecord(propsSource) ? propsSource : {};
171
+ return {
172
+ widgetId,
173
+ kind: 'form',
174
+ title: data.title || 'Details Required',
175
+ description: data.description,
176
+ body: data.body,
177
+ state: data.state,
178
+ metadata: data.metadata,
179
+ fields: data.fields || asFields(props.schema) || [],
180
+ submitLabel: data.submitLabel || readString(props.submitLabel),
181
+ actions: data.actions,
182
+ };
183
+ }
184
+
185
+ if (data.kind === 'list') {
186
+ return { ...data, widgetId, title: data.title || 'Task List', items: data.items || [] };
187
+ }
188
+
189
+ if (data.kind === 'choice') {
190
+ return { ...data, widgetId, title: data.title || 'Choose an Option' };
191
+ }
192
+
193
+ if (data.kind === 'message') {
194
+ return { ...data, widgetId, title: data.title || 'Message' };
195
+ }
196
+
197
+ throw new Error(`Unsupported UI widget kind: ${(data as { kind?: string }).kind || 'unknown'}`);
198
+ };
199
+
200
+ const uiToolDefinitions = {
201
+ render_ui_widget: {
202
+ description:
203
+ 'Render a small server-driven UI widget in the conversation. Prefer primitive kinds: message, choice, form, or list. Legacy presets approval and todo_list are accepted.',
204
+ inputSchema: renderWidgetSchema,
205
+ },
206
+ };
207
+
208
+ const uiPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
209
+ builder.on('action:render_ui_widget', async function* (event, context) {
210
+ const widget = normalizeWidget(event.data, context.state, event.meta?.toolCallId);
211
+ yield {
212
+ type: 'client:ui:widget',
213
+ data: widget,
214
+ meta: event.meta,
215
+ };
216
+ });
217
+ };
218
+
219
+ export const uiPlugin: Plugin = {
220
+ id: 'ui',
221
+ name: 'UI Widgets',
222
+ description: 'Render server-driven UI widgets (messages, choices, forms, lists) in the conversation.',
223
+ toolDefinitions: uiToolDefinitions,
224
+ factory: () => uiPluginRuntime(),
225
+ };
226
+
227
+ export default uiPlugin;
@@ -1,85 +1,136 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { MelonyPlugin } from 'melony';
5
- import { OpenBotEvent, OpenBotState } from '../app/types.js';
6
- import { aiSdkPlugin } from '../plugins/ai-sdk.js';
7
- import { storagePlugin } from '../plugins/storage.js';
8
- import { storageService } from '../services/storage.js';
9
- import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
10
- import { delegationPlugin } from '../plugins/delegation.js';
11
- import { mcpPlugin } from '../plugins/mcp.js';
12
- import { uiPlugin } from '../plugins/ui.js';
13
- import { orchestratorService } from '../harness/orchestrator.js';
4
+ import type { Plugin } from '../bus/plugin.js';
5
+ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
6
+ import { shellPlugin } from '../plugins/shell/index.js';
7
+ import { mcpPlugin } from '../plugins/mcp/index.js';
8
+ import { delegationPlugin } from '../plugins/delegation/index.js';
9
+ import { storageToolsPlugin } from '../plugins/storage-tools/index.js';
10
+ import { uiPlugin } from '../plugins/ui/index.js';
11
+ import { approvalPlugin } from '../plugins/approval/index.js';
12
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
14
13
 
15
14
  let pluginsDir: string | null = null;
16
15
  const loadedPlugins = new Set<string>();
16
+ const cache = new Map<string, Plugin>();
17
+
18
+ const BUILT_IN: Record<string, Plugin> = {
19
+ [aiSdkPlugin.id]: aiSdkPlugin,
20
+ [shellPlugin.id]: shellPlugin,
21
+ [mcpPlugin.id]: mcpPlugin,
22
+ [delegationPlugin.id]: delegationPlugin,
23
+ [storageToolsPlugin.id]: storageToolsPlugin,
24
+ [uiPlugin.id]: uiPlugin,
25
+ [approvalPlugin.id]: approvalPlugin,
26
+ };
17
27
 
18
28
  /**
19
- * Initializes the plugins directory.
29
+ * Parsed shape of a community plugin module. The `id` is intentionally omitted:
30
+ * the canonical id is the npm package name (== folder under `plugins/`),
31
+ * assigned by the caller.
20
32
  */
33
+ export type ParsedPluginModule = Omit<Plugin, 'id'>;
34
+
35
+ /** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
36
+ export function parsePluginModule(
37
+ module: Record<string, unknown>,
38
+ ): ParsedPluginModule | null {
39
+ const raw =
40
+ (module.plugin as Record<string, unknown> | undefined) ??
41
+ (module.default as Record<string, unknown> | undefined);
42
+
43
+ if (!raw || typeof raw !== 'object') return null;
44
+
45
+ const factory = raw.factory;
46
+ if (typeof factory !== 'function') return null;
47
+
48
+ const name = typeof raw.name === 'string' ? raw.name : '';
49
+ const description = typeof raw.description === 'string' ? raw.description : '';
50
+ const image = typeof raw.image === 'string' ? raw.image : undefined;
51
+ const defaultInstructions =
52
+ typeof raw.defaultInstructions === 'string' ? raw.defaultInstructions : undefined;
53
+ const configSchema = raw.configSchema as Plugin['configSchema'];
54
+ const toolDefinitions = raw.toolDefinitions as Plugin['toolDefinitions'];
55
+
56
+ return {
57
+ name,
58
+ description,
59
+ image,
60
+ defaultInstructions,
61
+ configSchema,
62
+ toolDefinitions,
63
+ factory: factory as Plugin['factory'],
64
+ };
65
+ }
66
+
67
+ /** Initialize the on-disk plugins directory (defaults to ~/.openbot/plugins). */
21
68
  export function initPlugins(dir?: string) {
22
69
  if (dir) {
23
70
  pluginsDir = dir;
24
71
  } else {
25
72
  const config = loadConfig();
26
73
  const baseDir = config.baseDir || DEFAULT_BASE_DIR;
27
- pluginsDir = path.join(resolvePath(baseDir), 'plugins');
74
+ pluginsDir = path.join(resolvePath(baseDir), DEFAULT_PLUGINS_DIR);
28
75
  }
29
76
  }
30
77
 
31
78
  /**
32
- * Resolves a plugin from its name and config.
79
+ * Resolve a Plugin by id. The id is either:
80
+ * - a built-in id (e.g. "ai-sdk", "shell"), or
81
+ * - an npm package name (e.g. "openbot-plugin-foo" or "@scope/foo"),
82
+ * in which case the folder layout is `plugins/<id>/dist/index.js`.
33
83
  */
34
- export async function resolvePlugin(
35
- pluginName: string,
36
- config: any = {},
37
- ): Promise<MelonyPlugin<OpenBotState, OpenBotEvent> | null> {
38
- // 1. Built-in plugins
39
- switch (pluginName) {
40
- case 'storage':
41
- return storagePlugin({ storage: storageService, ...config });
42
- case 'ai-sdk':
43
- return aiSdkPlugin({
44
- storage: storageService,
45
- ...config,
46
- });
47
- case 'delegation':
48
- return delegationPlugin();
49
- case 'mcp':
50
- return mcpPlugin();
51
- case 'ui':
52
- return uiPlugin();
84
+ export async function resolvePlugin(id: string): Promise<Plugin | null> {
85
+ if (cache.has(id)) return cache.get(id)!;
86
+ if (BUILT_IN[id]) {
87
+ cache.set(id, BUILT_IN[id]);
88
+ return BUILT_IN[id];
53
89
  }
54
90
 
55
- // 2. Search for external plugins in the initialized plugins directory
56
91
  if (!pluginsDir) {
57
92
  initPlugins();
58
93
  }
94
+ if (!pluginsDir) return null;
95
+
96
+ const distPath = path.join(pluginsDir, id, 'dist', 'index.js');
59
97
 
60
- if (pluginsDir) {
61
- const pluginDir = path.resolve(pluginsDir, pluginName);
62
- const distPath = path.join(pluginDir, 'dist', 'index.js');
63
-
64
- if (fs.existsSync(distPath)) {
65
- try {
66
- // Dynamic import needs file:// URL for absolute paths
67
- const module = await import(pathToFileURL(distPath).href);
68
- const factory = module.plugin.factory;
69
-
70
- if (typeof factory === 'function') {
71
- if (!loadedPlugins.has(pluginName)) {
72
- console.log(`[plugins] Loaded community plugin "${pluginName}" from ${distPath}`);
73
- loadedPlugins.add(pluginName);
74
- }
75
- return factory(config);
76
- }
77
- } catch (e) {
78
- console.warn(`[plugins] Failed to load plugin "${pluginName}" from ${distPath}:`, e);
79
- }
98
+ if (!fs.existsSync(distPath)) {
99
+ console.warn(`[plugins] Plugin "${id}" not found at ${distPath}.`);
100
+ return null;
101
+ }
102
+
103
+ try {
104
+ const module = await import(pathToFileURL(distPath).href);
105
+ const parsed = parsePluginModule(module as Record<string, unknown>);
106
+ if (!parsed) {
107
+ console.warn(`[plugins] Plugin "${id}" at ${distPath} has no recognizable export.`);
108
+ return null;
80
109
  }
110
+ const plugin: Plugin = { id, ...parsed, name: parsed.name || id };
111
+ cache.set(id, plugin);
112
+ if (!loadedPlugins.has(id)) {
113
+ console.log(`[plugins] Loaded community plugin "${id}" from ${distPath}`);
114
+ loadedPlugins.add(id);
115
+ }
116
+ return plugin;
117
+ } catch (e) {
118
+ console.warn(`[plugins] Failed to load plugin "${id}" from ${distPath}:`, e);
119
+ return null;
81
120
  }
121
+ }
122
+
123
+ /** Drop a single id from the in-memory cache (e.g. after fresh install). */
124
+ export function invalidatePlugin(id: string): void {
125
+ cache.delete(id);
126
+ loadedPlugins.delete(id);
127
+ }
128
+
129
+ /** List built-in plugins (for marketplace/registry views). */
130
+ export function listBuiltInPlugins(): Plugin[] {
131
+ return Object.values(BUILT_IN);
132
+ }
82
133
 
83
- console.warn(`[plugins] Plugin "${pluginName}" not found in registry or external directory.`);
84
- return null;
134
+ export function getPluginsDir(): string | null {
135
+ return pluginsDir;
85
136
  }
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { exec } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import {
7
+ DEFAULT_PLUGINS_DIR,
8
+ DEFAULT_BASE_DIR,
9
+ loadConfig,
10
+ resolvePath,
11
+ } from '../app/config.js';
12
+ import { invalidatePlugin } from '../registry/plugins.js';
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ export interface InstallOptions {
17
+ packageName: string;
18
+ version?: string;
19
+ }
20
+
21
+ export interface InstalledPlugin {
22
+ /** npm package name; doubles as the plugin id used everywhere else. */
23
+ name: string;
24
+ version: string;
25
+ }
26
+
27
+ const getPluginsDir = (): string => {
28
+ const config = loadConfig();
29
+ const baseDir = resolvePath(config.baseDir || DEFAULT_BASE_DIR);
30
+ return path.join(baseDir, DEFAULT_PLUGINS_DIR);
31
+ };
32
+
33
+ /**
34
+ * Lifecycle for community-built plugins distributed via npm.
35
+ * Each plugin is installed to `<plugins>/<npm-name>/` and is identified
36
+ * everywhere (AGENT.md `plugins[].id`, registry, runtime resolution) by its
37
+ * npm name. Scoped packages (`@scope/foo`) live under `<plugins>/@scope/foo/`.
38
+ */
39
+ export const pluginService = {
40
+ isInstalled: async (packageName: string): Promise<boolean> => {
41
+ const finalPath = path.join(getPluginsDir(), packageName);
42
+ return existsSync(path.join(finalPath, 'dist', 'index.js'));
43
+ },
44
+
45
+ install: async ({ packageName, version }: InstallOptions): Promise<InstalledPlugin> => {
46
+ const pluginsDir = getPluginsDir();
47
+ await fs.mkdir(pluginsDir, { recursive: true });
48
+
49
+ const finalPath = path.join(pluginsDir, packageName);
50
+
51
+ if (existsSync(path.join(finalPath, 'package.json'))) {
52
+ try {
53
+ const pkgJson = JSON.parse(
54
+ await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'),
55
+ );
56
+ if (!version || pkgJson.version === version) {
57
+ console.log(
58
+ `[plugins] ${packageName}${version ? `@${version}` : ''} is already installed.`,
59
+ );
60
+ return { name: pkgJson.name, version: pkgJson.version };
61
+ }
62
+ } catch {
63
+ // corrupted; reinstall below
64
+ }
65
+ }
66
+
67
+ const target = version ? `${packageName}@${version}` : packageName;
68
+ console.log(`[plugins] Installing ${target} to ${pluginsDir}...`);
69
+
70
+ const tempDir = path.join(pluginsDir, '.tmp_' + Date.now());
71
+ try {
72
+ await fs.mkdir(tempDir, { recursive: true });
73
+ await execAsync(`npm install ${target} --no-save --prefix "${tempDir}"`);
74
+
75
+ const installedPath = path.join(tempDir, 'node_modules', packageName);
76
+ if (!existsSync(installedPath)) {
77
+ throw new Error(`npm did not produce ${installedPath}`);
78
+ }
79
+
80
+ await fs.mkdir(path.dirname(finalPath), { recursive: true });
81
+ await fs.rm(finalPath, { recursive: true, force: true });
82
+ await fs.rename(installedPath, finalPath);
83
+
84
+ console.log(`[plugins] Running npm install in ${finalPath}...`);
85
+ try {
86
+ await execAsync(`npm install`, { cwd: finalPath });
87
+ console.log(`[plugins] npm install completed in ${finalPath}`);
88
+ } catch (e) {
89
+ console.warn(`[plugins] Failed to run npm install in ${finalPath}:`, e);
90
+ }
91
+
92
+ const pkgJson = JSON.parse(
93
+ await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'),
94
+ );
95
+
96
+ invalidatePlugin(packageName);
97
+ return { name: pkgJson.name, version: pkgJson.version };
98
+ } catch (error) {
99
+ console.error(`[plugins] Failed to install ${packageName}:`, error);
100
+ throw new Error(
101
+ `Failed to install plugin ${packageName}: ${(error as Error).message}`,
102
+ );
103
+ } finally {
104
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
105
+ }
106
+ },
107
+
108
+ uninstall: async (packageName: string): Promise<void> => {
109
+ const pluginsDir = getPluginsDir();
110
+ const pluginPath = path.join(pluginsDir, packageName);
111
+
112
+ try {
113
+ await fs.rm(pluginPath, { recursive: true, force: true });
114
+ invalidatePlugin(packageName);
115
+ console.log(`[plugins] Uninstalled plugin ${packageName}`);
116
+
117
+ if (packageName.startsWith('@')) {
118
+ const scopeDir = path.dirname(pluginPath);
119
+ try {
120
+ const remaining = await fs.readdir(scopeDir);
121
+ if (remaining.length === 0) await fs.rmdir(scopeDir);
122
+ } catch {
123
+ // ignore
124
+ }
125
+ }
126
+ } catch (error) {
127
+ console.error(`[plugins] Failed to uninstall ${packageName}:`, error);
128
+ throw new Error(
129
+ `Failed to uninstall plugin ${packageName}: ${(error as Error).message}`,
130
+ );
131
+ }
132
+ },
133
+ };