@bahulam/code 0.1.10 → 0.1.11

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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Plugin Executor — dynamic import() of plugin tool handler modules.
3
+ *
4
+ * Plugin tools are .mjs / .js files that export at minimum a `call(input)` function.
5
+ * They are loaded relative to the plugin directory.
6
+ */
7
+
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import path from 'path';
10
+ import { makePluginState } from './state.mjs';
11
+
12
+ /**
13
+ * Load a plugin tool handler by resolving its path relative to the plugin directory.
14
+ * @param {string} pluginDir - Absolute path to the plugin directory
15
+ * @param {string} handlerPath - Relative path from plugin.yaml (e.g. ./tools/my-tool.mjs)
16
+ * @returns {Promise<object|null>} { name, description, inputSchema, call } or null
17
+ */
18
+ export async function loadPluginTool(pluginDir, handlerPath) {
19
+ try {
20
+ const absPath = path.resolve(pluginDir, handlerPath);
21
+ const fileUrl = pathToFileURL(absPath).href;
22
+
23
+ // Cache-busting for development: append timestamp
24
+ const url = `${fileUrl}?t=${Date.now()}`;
25
+ const mod = await import(url);
26
+
27
+ // The module should export at minimum: call(input) => { success, output }
28
+ if (typeof mod.call !== 'function') {
29
+ if (process.env.DEBUG) {
30
+ console.error(`Plugin handler ${handlerPath} does not export a call() function`);
31
+ }
32
+ return null;
33
+ }
34
+
35
+ return {
36
+ name: mod.name || path.basename(handlerPath, path.extname(handlerPath)),
37
+ description: mod.description || '',
38
+ inputSchema: mod.inputSchema || mod.input_schema || { type: 'object', properties: {} },
39
+ call: mod.call,
40
+ validateInput: mod.validateInput || null,
41
+ };
42
+ } catch (err) {
43
+ if (process.env.DEBUG) {
44
+ console.error(`Failed to load plugin tool handler ${handlerPath}: ${err.message}`);
45
+ }
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create a plugin tool executor for a given plugin directory.
52
+ * Loads all tool handlers and returns an execute function.
53
+ *
54
+ * @param {object} manifest - Normalized plugin manifest
55
+ * @returns {Promise<{ execute(name, args): Promise<object>, list(): string[] }>}
56
+ */
57
+ /**
58
+ * Create a plugin tool executor for a given plugin directory.
59
+ * Loads all tool handlers and returns an execute function.
60
+ *
61
+ * @param {object} manifest - Normalized plugin manifest
62
+ * @param {object} [opts]
63
+ * @param {(evt: {plugin: string, op: string, kind: string, target: string, at: string}) => void} [opts.stateEmit]
64
+ * Called (debounced) after every state write commits. The workspace
65
+ * server threads this in so state changes turn into SSE events for
66
+ * live view updates — the Shared Blackboard's reactive pulse.
67
+ * @returns {Promise<{ execute(name, args): Promise<object>, list(): string[], state: object|null }>}
68
+ */
69
+ export async function createPluginToolExecutor(manifest, opts = {}) {
70
+ const pluginDir = manifest._dir || '';
71
+ const tools = manifest.spec?.tools || [];
72
+ const handlers = new Map(); // name → { handler, toolDef }
73
+ const pluginName = manifest.metadata?.name || '';
74
+
75
+ for (const toolDef of tools) {
76
+ if (!toolDef.handler) continue;
77
+ const handler = await loadPluginTool(pluginDir, toolDef.handler);
78
+ if (handler) {
79
+ handlers.set(toolDef.name, { handler, toolDef });
80
+ }
81
+ }
82
+
83
+ // One state instance per plugin, opened lazily so plugins that never
84
+ // touch state don't create empty ~/.bahulam/data/<name> directories.
85
+ let _state = null;
86
+ function getState() {
87
+ if (!pluginName) return null; // no name → no isolation → refuse state
88
+ if (_state) return _state;
89
+ _state = makePluginState(pluginName, { emit: opts.stateEmit || null });
90
+ return _state;
91
+ }
92
+
93
+ return {
94
+ execute: async (name, args, options = {}) => {
95
+ const entry = handlers.get(name);
96
+ if (!entry) {
97
+ return { success: false, output: `Plugin tool not found: ${name}` };
98
+ }
99
+ // Inject the shared-blackboard handle. Handlers opt in by naming
100
+ // it in their signature: `async call(args, { state })`. The
101
+ // getter defers opening the SQLite file until the first access,
102
+ // so handlers that don't use state pay no cost.
103
+ const handlerOpts = {
104
+ ...options,
105
+ get state() { return getState(); },
106
+ pluginName,
107
+ };
108
+ try {
109
+ const result = await entry.handler.call(args || {}, handlerOpts);
110
+ return result?.success !== false
111
+ ? { success: true, output: result?.output ?? result, _tool: name, _plugin: pluginName }
112
+ : { success: false, output: result?.output ?? String(result), _tool: name, _plugin: pluginName };
113
+ } catch (err) {
114
+ return { success: false, output: `Plugin tool error (${name}): ${err.message}`, _tool: name, _plugin: pluginName };
115
+ }
116
+ },
117
+ list: () => [...handlers.keys()],
118
+ /** Get (or open) the plugin's state handle — used by the view API. */
119
+ get state() { return getState(); },
120
+ };
121
+ }
@@ -1,138 +1,138 @@
1
1
  /**
2
- * Plugin Loader — load plugins from directory, git, or npm.
2
+ * Plugin Loader — high-level facade for installing, listing, and removing plugins.
3
3
  *
4
- * Plugins can provide: tools, agents, skills, hooks.
5
- * Plugin format: a directory with a plugin.json manifest.
4
+ * Uses PluginRegistry for scanning/loading, and provides git-based install.
5
+ * Plugin format: a directory with plugin.yaml (bahulam.plugin/1).
6
6
  */
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
11
  import { execSync } from 'child_process';
12
+ import { PluginRegistry } from './registry.mjs';
12
13
 
13
14
  export class PluginLoader {
14
- /**
15
- * @param {string} [pluginDir] - directory to scan for plugins
16
- */
17
- constructor(pluginDir) {
18
- this.pluginDir = pluginDir ||
19
- path.join(os.homedir(), '.claude', 'plugins');
20
- this.plugins = new Map();
21
- }
22
-
23
- /**
24
- * Load plugins from the plugin directory.
25
- * @returns {Array<object>} loaded plugin manifests
26
- */
27
- async loadFromDirectory(dir) {
28
- const targetDir = dir || this.pluginDir;
29
- const loaded = [];
30
-
31
- try {
32
- if (!fs.existsSync(targetDir)) return loaded;
33
-
34
- const entries = fs.readdirSync(targetDir, { withFileTypes: true });
35
- for (const entry of entries) {
36
- if (!entry.isDirectory()) continue;
37
-
38
- const manifestPath = path.join(targetDir, entry.name, 'plugin.json');
39
- if (!fs.existsSync(manifestPath)) continue;
40
-
41
- try {
42
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
43
- manifest._dir = path.join(targetDir, entry.name);
44
- manifest._name = entry.name;
45
- this.plugins.set(manifest.name || entry.name, manifest);
46
- loaded.push(manifest);
47
- } catch {
48
- // Skip malformed plugins
49
- }
50
- }
51
- } catch {
52
- // Directory not readable
15
+ /**
16
+ * @param {Object} [options]
17
+ * @param {string} [options.pluginDir] - Primary plugin directory
18
+ * @param {string[]} [options.pluginDirs] - Additional plugin directories
19
+ * @param {string[]} [options.disabled] - Plugin names to disable
20
+ */
21
+ constructor(options = {}) {
22
+ const { pluginDir, pluginDirs, disabled } = options;
23
+ this.registry = new PluginRegistry({
24
+ pluginDir: pluginDir || path.join(os.homedir(), '.bahulam', 'plugins'),
25
+ pluginDirs,
26
+ disabled,
27
+ });
28
+ this.pluginDir = pluginDir || path.join(os.homedir(), '.bahulam', 'plugins');
29
+ }
30
+
31
+ /**
32
+ * Load all plugins from registered directories.
33
+ * @returns {PluginRegistry}
34
+ */
35
+ load() {
36
+ this.registry.scan();
37
+ return this.registry;
38
+ }
39
+
40
+ /**
41
+ * Load plugins from a specific directory (scans subdirectories).
42
+ * @param {string} dir
43
+ * @returns {PluginRegistry}
44
+ */
45
+ loadFromDirectory(dir) {
46
+ this.registry._scanDir(dir);
47
+ return this.registry;
48
+ }
49
+
50
+ /**
51
+ * Clone a plugin from a git repo and load it.
52
+ * @param {string} repoUrl - git repository URL
53
+ * @param {string} [name] - plugin name (default: repo name)
54
+ * @returns {object|null} loaded manifest
55
+ */
56
+ loadFromGit(repoUrl, name) {
57
+ const pluginName = name || repoUrl.split('/').pop()?.replace('.git', '') || 'plugin';
58
+ const targetDir = path.join(this.pluginDir, pluginName);
59
+
60
+ try {
61
+ fs.mkdirSync(this.pluginDir, { recursive: true });
62
+
63
+ if (fs.existsSync(targetDir)) {
64
+ // Update existing
65
+ execSync('git pull', { cwd: targetDir, stdio: 'pipe' });
66
+ } else {
67
+ // Clone new
68
+ execSync(`git clone --depth 1 ${repoUrl} ${targetDir}`, { stdio: 'pipe' });
69
+ }
70
+
71
+ const manifestPath = path.join(targetDir, 'plugin.yaml');
72
+ const altPath = path.join(targetDir, 'plugin.json');
73
+ const exists = fs.existsSync(manifestPath) ? manifestPath : (fs.existsSync(altPath) ? altPath : null);
74
+
75
+ if (exists) {
76
+ const { parsePluginManifestFile } = await import('./manifest.mjs');
77
+ const manifest = parsePluginManifestFile(exists);
78
+ if (manifest) {
79
+ this.registry.register(manifest);
80
+ return manifest;
53
81
  }
54
-
55
- return loaded;
56
- }
57
-
58
- /**
59
- * Clone a plugin from a git repo and load it.
60
- * @param {string} repoUrl - git repository URL
61
- * @param {string} [name] - plugin name (default: repo name)
62
- * @returns {object|null} loaded manifest
63
- */
64
- async loadFromGit(repoUrl, name) {
65
- const pluginName = name || repoUrl.split('/').pop()?.replace('.git', '') || 'plugin';
66
- const targetDir = path.join(this.pluginDir, pluginName);
67
-
68
- try {
69
- fs.mkdirSync(this.pluginDir, { recursive: true });
70
-
71
- if (fs.existsSync(targetDir)) {
72
- // Update existing
73
- execSync('git pull', { cwd: targetDir, stdio: 'pipe' });
74
- } else {
75
- // Clone new
76
- execSync(`git clone --depth 1 ${repoUrl} ${targetDir}`, { stdio: 'pipe' });
77
- }
78
-
79
- const manifestPath = path.join(targetDir, 'plugin.json');
80
- if (fs.existsSync(manifestPath)) {
81
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
82
- manifest._dir = targetDir;
83
- manifest._name = pluginName;
84
- this.plugins.set(manifest.name || pluginName, manifest);
85
- return manifest;
86
- }
87
- } catch {
88
- // Git operation failed
89
- }
90
-
91
- return null;
82
+ }
83
+ } catch (err) {
84
+ if (process.env.DEBUG) {
85
+ console.error(`Failed to clone plugin ${repoUrl}: ${err.message}`);
86
+ }
92
87
  }
93
88
 
94
- /**
95
- * Get all installed plugins.
96
- * @returns {Array<object>}
97
- */
98
- getInstalledPlugins() {
99
- return [...this.plugins.values()];
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Get all installed plugins.
94
+ * @returns {object[]}
95
+ */
96
+ getInstalledPlugins() {
97
+ return this.registry.list();
98
+ }
99
+
100
+ /**
101
+ * Get a plugin by name.
102
+ * @param {string} name
103
+ * @returns {object|undefined}
104
+ */
105
+ getPlugin(name) {
106
+ return this.registry.get(name);
107
+ }
108
+
109
+ /**
110
+ * Remove a plugin by name.
111
+ * @param {string} name
112
+ * @returns {boolean}
113
+ */
114
+ removePlugin(name) {
115
+ const plugin = this.registry.get(name);
116
+ if (!plugin) return false;
117
+
118
+ try {
119
+ if (plugin._dir && fs.existsSync(plugin._dir)) {
120
+ fs.rmSync(plugin._dir, { recursive: true, force: true });
121
+ }
122
+ } catch (err) {
123
+ if (process.env.DEBUG) {
124
+ console.error(`Failed to remove plugin directory ${plugin._dir}: ${err.message}`);
125
+ }
100
126
  }
101
127
 
102
- /**
103
- * Get a plugin by name.
104
- * @param {string} name
105
- * @returns {object|undefined}
106
- */
107
- getPlugin(name) {
108
- return this.plugins.get(name);
109
- }
110
-
111
- /**
112
- * Remove a plugin by name.
113
- * @param {string} name
114
- * @returns {boolean}
115
- */
116
- removePlugin(name) {
117
- const plugin = this.plugins.get(name);
118
- if (!plugin) return false;
119
-
120
- try {
121
- if (plugin._dir && fs.existsSync(plugin._dir)) {
122
- fs.rmSync(plugin._dir, { recursive: true, force: true });
123
- }
124
- } catch {
125
- // Best effort
126
- }
127
-
128
- return this.plugins.delete(name);
129
- }
130
-
131
- /**
132
- * Get plugin count.
133
- * @returns {number}
134
- */
135
- count() {
136
- return this.plugins.size;
137
- }
138
- }
128
+ return this.registry.remove(name);
129
+ }
130
+
131
+ /**
132
+ * Get plugin count.
133
+ * @returns {number}
134
+ */
135
+ count() {
136
+ return this.registry.count();
137
+ }
138
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Plugin Manifest Parser — parse and validate bahulam.plugin/1 manifests.
3
+ *
4
+ * Supports YAML (plugin.yaml) format.
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { load as yamlLoad } from 'js-yaml';
10
+
11
+ /**
12
+ * Parse a YAML text string into an object using js-yaml.
13
+ * @param {string} text - Raw YAML content
14
+ * @returns {object}
15
+ */
16
+ function parseYaml(text) {
17
+ return yamlLoad(text) || {};
18
+ }
19
+
20
+ function normalizeToolNames(value) {
21
+ if (!Array.isArray(value)) return [];
22
+ return value.map(item => {
23
+ if (typeof item === 'string') return item.trim();
24
+ if (item && typeof item === 'object') return String(item.name || item.tool || item.id || '').trim();
25
+ return '';
26
+ }).filter(Boolean);
27
+ }
28
+
29
+ function loadAgentHandler(agentDef, pluginDir) {
30
+ const handler = String(agentDef.handler || agentDef.file || '').trim();
31
+ if (!handler || !pluginDir) return {};
32
+ try {
33
+ const handlerPath = path.resolve(pluginDir, handler);
34
+ const raw = fs.readFileSync(handlerPath, 'utf-8');
35
+ return path.extname(handlerPath).toLowerCase() === '.json'
36
+ ? JSON.parse(raw)
37
+ : parseYaml(raw);
38
+ } catch (err) {
39
+ if (process.env.DEBUG) {
40
+ console.error(`Failed to load plugin agent handler ${handler}: ${err.message}`);
41
+ }
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
47
+ const handlerConfig = loadAgentHandler(agentDef, pluginDir);
48
+ const metadata = handlerConfig.metadata || handlerConfig.meta || {};
49
+ const agent = handlerConfig.agent || handlerConfig.spec?.agent || {};
50
+ const handlerTools = (
51
+ handlerConfig.tools
52
+ || handlerConfig.spec?.tools
53
+ || agent.tools
54
+ || []
55
+ );
56
+ const inlineTools = normalizeToolNames(agentDef.tools);
57
+
58
+ return {
59
+ slug: agentDef.slug || metadata.slug || handlerConfig.slug || metadata.name || handlerConfig.name || agentDef.name || '',
60
+ name: agentDef.name || metadata.name || handlerConfig.name || agentDef.slug || '',
61
+ description: agentDef.description || metadata.description || handlerConfig.description || '',
62
+ role: agentDef.role || metadata.role || handlerConfig.role || 'specialist',
63
+ system_prompt: (
64
+ agentDef.system_prompt
65
+ || agentDef.systemPrompt
66
+ || agentDef.prompt
67
+ || agent.system_prompt
68
+ || agent.systemPrompt
69
+ || agent.prompt
70
+ || handlerConfig.system_prompt
71
+ || handlerConfig.prompt
72
+ || ''
73
+ ),
74
+ tools: inlineTools.length ? inlineTools : normalizeToolNames(handlerTools),
75
+ model: agentDef.model || agent.model || handlerConfig.model || null,
76
+ models: agentDef.models || agent.models || handlerConfig.models || undefined,
77
+ max_tokens: agentDef.max_tokens || agent.max_tokens || handlerConfig.max_tokens || undefined,
78
+ max_iterations: agentDef.max_iterations || agent.max_iterations || handlerConfig.max_iterations || undefined,
79
+ handler: agentDef.handler || agentDef.file || '',
80
+ source: `plugin:${pluginName}`,
81
+ source_scope: 'plugin',
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Parse a plugin manifest from YAML text.
87
+ * @param {string} yamlText - Raw YAML content
88
+ * @param {string} [filePath] - Source path for error messages
89
+ * @returns {object|null} Normalized manifest or null on failure
90
+ */
91
+ export function parsePluginManifest(yamlText, filePath = '') {
92
+ try {
93
+ const raw = parseYaml(yamlText);
94
+ return normalizeManifest(raw, filePath);
95
+ } catch (err) {
96
+ if (process.env.DEBUG) {
97
+ console.error(`Failed to parse plugin manifest ${filePath}: ${err.message}`);
98
+ }
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Parse a plugin manifest from a file.
105
+ * @param {string} manifestPath - Path to plugin.yaml or plugin.json
106
+ * @returns {object|null}
107
+ */
108
+ export function parsePluginManifestFile(manifestPath) {
109
+ try {
110
+ const ext = path.extname(manifestPath).toLowerCase();
111
+ const content = fs.readFileSync(manifestPath, 'utf-8');
112
+ if (ext === '.json') {
113
+ const raw = JSON.parse(content);
114
+ return normalizeManifest(raw, manifestPath);
115
+ }
116
+ return parsePluginManifest(content, manifestPath);
117
+ } catch (err) {
118
+ if (process.env.DEBUG) {
119
+ console.error(`Failed to read plugin manifest ${manifestPath}: ${err.message}`);
120
+ }
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Normalize and validate a raw manifest object.
127
+ * @param {object} raw
128
+ * @param {string} [source]
129
+ * @returns {object|null}
130
+ */
131
+ export function normalizeManifest(raw, source = '') {
132
+ if (!raw || typeof raw !== 'object') return null;
133
+
134
+ const apiVersion = raw.apiVersion || raw.api_version || '';
135
+ if (apiVersion !== 'bahulam.plugin/1') {
136
+ if (process.env.DEBUG) {
137
+ console.warn(`Unsupported plugin apiVersion: ${apiVersion} in ${source}`);
138
+ }
139
+ return null;
140
+ }
141
+
142
+ const meta = raw.metadata || raw.meta || {};
143
+ const spec = raw.spec || raw.plugin || {};
144
+ const name = meta.name || spec.name || '';
145
+ if (!name) {
146
+ if (process.env.DEBUG) {
147
+ console.warn(`Plugin manifest missing name: ${source}`);
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // Normalize agents
153
+ const agents = [];
154
+ const pluginDir = source ? path.dirname(source) : '';
155
+ for (const agentDef of (spec.agents || [])) {
156
+ const agent = normalizeAgentDef(agentDef, name, pluginDir);
157
+ if (agent.slug) agents.push(agent);
158
+ }
159
+
160
+ // Normalize tools
161
+ const tools = [];
162
+ for (const toolDef of (spec.tools || [])) {
163
+ const tool = {
164
+ name: toolDef.name || '',
165
+ description: toolDef.description || '',
166
+ input_schema: toolDef.parameters || toolDef.input_schema || toolDef.inputSchema || { type: 'object', properties: {} },
167
+ handler: toolDef.handler || toolDef.file || '',
168
+ plugin_name: name,
169
+ };
170
+ if (tool.name) tools.push(tool);
171
+ }
172
+
173
+ // Normalize workspace
174
+ const workspace = spec.workspace || {};
175
+ if (workspace.views && !Array.isArray(workspace.views)) {
176
+ workspace.views = [];
177
+ }
178
+
179
+ // Normalize MCP servers — the Plugin=MCP+UX story. Two sources are
180
+ // merged so authors can either:
181
+ // (a) declare mcpServers: {} inline in plugin.yaml (Bahulam-native)
182
+ // (b) drop a sibling mcp.json in Claude-Desktop format (portable —
183
+ // any config that works in Claude Desktop / Cursor / Cline
184
+ // transfers with zero edits)
185
+ // Inline wins on name collision so authors can override a portable
186
+ // config for the local plugin without editing mcp.json.
187
+ const mcpServers = _readMcpServers(spec.mcpServers, source);
188
+
189
+ return {
190
+ apiVersion,
191
+ kind: raw.kind || 'Plugin',
192
+ metadata: {
193
+ name,
194
+ version: meta.version || '0.0.0',
195
+ description: meta.description || '',
196
+ author: meta.author || '',
197
+ repository: meta.repository || '',
198
+ },
199
+ spec: {
200
+ tools,
201
+ agents,
202
+ workspace,
203
+ mcpServers,
204
+ },
205
+ source,
206
+ _dir: source ? path.dirname(source) : '',
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Merge inline `mcpServers:` from plugin.yaml with a sibling mcp.json.
212
+ * Both should be dicts of `{<name>: {command|url, args?, env?, headers?}}`
213
+ * following the Claude Desktop convention. Returns `{}` when neither is
214
+ * present so callers can iterate without a nullability check.
215
+ * @param {object|null|undefined} inline
216
+ * @param {string} manifestPath used to locate mcp.json next to it
217
+ * @returns {Object<string, object>}
218
+ */
219
+ function _readMcpServers(inline, manifestPath) {
220
+ const merged = {};
221
+ // Sibling mcp.json first — inline overrides on name collision.
222
+ if (manifestPath) {
223
+ const jsonPath = path.join(path.dirname(manifestPath), 'mcp.json');
224
+ if (fs.existsSync(jsonPath)) {
225
+ try {
226
+ const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
227
+ const servers = raw?.mcpServers || raw?.mcp_servers || raw || {};
228
+ if (servers && typeof servers === 'object') {
229
+ for (const [name, cfg] of Object.entries(servers)) {
230
+ if (cfg && typeof cfg === 'object') merged[name] = _normalizeMcpServer(cfg);
231
+ }
232
+ }
233
+ } catch (err) {
234
+ if (process.env.DEBUG) console.error(`Failed to read ${jsonPath}: ${err.message}`);
235
+ }
236
+ }
237
+ }
238
+ if (inline && typeof inline === 'object') {
239
+ for (const [name, cfg] of Object.entries(inline)) {
240
+ if (cfg && typeof cfg === 'object') merged[name] = _normalizeMcpServer(cfg);
241
+ }
242
+ }
243
+ return merged;
244
+ }
245
+
246
+ function _normalizeMcpServer(cfg) {
247
+ return {
248
+ command: cfg.command || undefined,
249
+ args: Array.isArray(cfg.args) ? cfg.args : undefined,
250
+ env: cfg.env && typeof cfg.env === 'object' ? cfg.env : undefined,
251
+ url: cfg.url || undefined,
252
+ headers: cfg.headers && typeof cfg.headers === 'object' ? cfg.headers : undefined,
253
+ transport: cfg.transport || undefined,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Validate a normalized manifest and return errors.
259
+ * @param {object} manifest
260
+ * @returns {{ valid: boolean, errors: string[] }}
261
+ */
262
+ export function validatePluginManifest(manifest) {
263
+ const errors = [];
264
+
265
+ if (!manifest) {
266
+ return { valid: false, errors: ['Manifest is null or undefined'] };
267
+ }
268
+
269
+ if (manifest.apiVersion !== 'bahulam.plugin/1') {
270
+ errors.push(`Unsupported apiVersion: ${manifest.apiVersion}. Expected bahulam.plugin/1`);
271
+ }
272
+
273
+ if (!manifest.metadata?.name) {
274
+ errors.push('Plugin metadata.name is required');
275
+ }
276
+
277
+ if (manifest.spec) {
278
+ for (const tool of (manifest.spec.tools || [])) {
279
+ if (!tool.name) errors.push('Tool missing name');
280
+ if (!tool.handler) errors.push(`Tool "${tool.name || '(unnamed)'}" missing handler path`);
281
+ }
282
+ for (const agent of (manifest.spec.agents || [])) {
283
+ if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
284
+ }
285
+ }
286
+
287
+ return {
288
+ valid: errors.length === 0,
289
+ errors,
290
+ };
291
+ }