@bahulam/code 0.1.10 → 0.1.12

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 (52) hide show
  1. package/package.json +5 -2
  2. package/src/agents/loader.mjs +26 -4
  3. package/src/auth/bahulam-auth.mjs +2 -13
  4. package/src/auth/tarang-auth.mjs +313 -0
  5. package/src/commands/agent.mjs +7 -7
  6. package/src/commands/plugin-manage.mjs +449 -0
  7. package/src/commands/plugin.mjs +247 -0
  8. package/src/config/cli-args.mjs +16 -0
  9. package/src/config/env.mjs +2 -0
  10. package/src/config/hook-runner.mjs +8 -8
  11. package/src/config/memory-loader.mjs +7 -3
  12. package/src/config/settings-loader.mjs +5 -3
  13. package/src/core/attachments.mjs +2 -2
  14. package/src/core/background-tasks.mjs +186 -0
  15. package/src/core/headless.mjs +59 -3
  16. package/src/core/local-agent.mjs +10 -1
  17. package/src/core/local-store.mjs +10 -10
  18. package/src/core/paths.mjs +10 -96
  19. package/src/core/policy-resolver.mjs +1 -1
  20. package/src/core/project-context-loader.mjs +2 -2
  21. package/src/core/risk-tier.mjs +1 -0
  22. package/src/core/stream-client.mjs +148 -1
  23. package/src/core/system-prompt.mjs +31 -12
  24. package/src/core/tool-executor.mjs +457 -12
  25. package/src/local-service/agent-relay.mjs +139 -12
  26. package/src/local-service/server.mjs +345 -20
  27. package/src/orchestration/approval.mjs +30 -0
  28. package/src/orchestration/completion-triggers.mjs +40 -0
  29. package/src/orchestration/dispatch.mjs +118 -0
  30. package/src/orchestration/events.mjs +19 -0
  31. package/src/orchestration/graph.mjs +126 -0
  32. package/src/orchestration/node-runner.mjs +193 -0
  33. package/src/orchestration/runner.mjs +200 -0
  34. package/src/plugins/executor.mjs +121 -0
  35. package/src/plugins/loader.mjs +123 -123
  36. package/src/plugins/manifest.mjs +291 -0
  37. package/src/plugins/preflight.mjs +227 -0
  38. package/src/plugins/registry.mjs +233 -0
  39. package/src/plugins/state.mjs +290 -0
  40. package/src/terminal/agents.mjs +26 -4
  41. package/src/terminal/init.mjs +2 -2
  42. package/src/terminal/main.mjs +83 -4
  43. package/src/terminal/repl-explore.mjs +1 -1
  44. package/src/terminal/repl-render.mjs +67 -12
  45. package/src/terminal/repl-state.mjs +4 -2
  46. package/src/terminal/repl.mjs +621 -99
  47. package/src/tools/agent.mjs +6 -2
  48. package/src/tools/analyze-image.mjs +1 -1
  49. package/src/tools/project-overview.mjs +7 -7
  50. package/src/tools/registry.mjs +88 -4
  51. package/src/ui/slash-commands.mjs +19 -1
  52. package/src/ui/sub-agent.mjs +14 -8
@@ -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 loadAgentFile(agentDef, pluginDir) {
30
+ const file = String(agentDef.file || agentDef.handler || '').trim();
31
+ if (!file || !pluginDir) return {};
32
+ try {
33
+ const filePath = path.resolve(pluginDir, file);
34
+ const raw = fs.readFileSync(filePath, 'utf-8');
35
+ return path.extname(filePath).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 file ${file}: ${err.message}`);
41
+ }
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
47
+ const fileConfig = loadAgentFile(agentDef, pluginDir);
48
+ const metadata = fileConfig.metadata || fileConfig.meta || {};
49
+ const agent = fileConfig.agent || fileConfig.spec?.agent || {};
50
+ const fileTools = (
51
+ fileConfig.tools
52
+ || fileConfig.spec?.tools
53
+ || agent.tools
54
+ || []
55
+ );
56
+ const inlineTools = normalizeToolNames(agentDef.tools);
57
+
58
+ return {
59
+ slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
60
+ name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
61
+ description: agentDef.description || metadata.description || fileConfig.description || '',
62
+ role: agentDef.role || metadata.role || fileConfig.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
+ || fileConfig.system_prompt
71
+ || fileConfig.prompt
72
+ || ''
73
+ ),
74
+ tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
75
+ model: agentDef.model || agent.model || fileConfig.model || null,
76
+ models: agentDef.models || agent.models || fileConfig.models || undefined,
77
+ max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
78
+ max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
79
+ file: agentDef.file || agentDef.handler || '',
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
+ tool: toolDef.tool || toolDef.file || toolDef.handler || '',
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.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
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
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Plugin preflight — hard install-time validation.
3
+ *
4
+ * Runs on every install (and available as `bahulam plugin validate <path>`).
5
+ * Rejects a plugin with clear per-issue errors BEFORE the installer marks
6
+ * the install successful. The installer rolls back on any hard error.
7
+ *
8
+ * Checks:
9
+ * 1. Manifest schema — apiVersion, metadata.name/version, structure
10
+ * 2. Tool names — regex, length, no shadowing built-ins, no dupes
11
+ * 3. Tool handlers — file exists, imports cleanly, exports `call`
12
+ * 4. Tool schemas — parameters is a valid JSON-Schema object
13
+ * 5. Sub-agents — slug regex, tool references resolve to real tools
14
+ * 6. Workspace views — source file exists, path stays inside plugin dir
15
+ * 7. Collisions — name doesn't match an already-installed plugin
16
+ *
17
+ * Soft warnings (don't block install) surface as `warnings[]`.
18
+ */
19
+
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
25
+
26
+ const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
27
+ const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
28
+
29
+ // Names the built-in CLI toolMap owns. Plugin tools that shadow these
30
+ // are dropped silently at schema-registration time (backend
31
+ // sanitize_client_tools), so we reject upfront with a clear error.
32
+ export const RESERVED_TOOL_NAMES = new Set([
33
+ // read
34
+ 'read_file', 'read_files', 'read_batch', 'read_attachment', 'search_code',
35
+ 'search_files', 'grep', 'list_files', 'analyze_code', 'get_file_info',
36
+ 'get_project_overview', 'git_status', 'git_diff', 'validate_file',
37
+ 'validate_structure', 'validate_build', 'lint_check', 'run_tests',
38
+ // write
39
+ 'write_file', 'write_project', 'edit_file', 'delete_file', 'shell',
40
+ 'analyze_image', 'generate_image',
41
+ // agent/skill/workflow admin
42
+ 'ask_user', 'agent_create', 'agent_sync', 'agents_list',
43
+ 'skill_install', 'skill_update', 'skill_remove', 'skill_view', 'skills_list',
44
+ 'workflow_create_multi', 'workflow_sync_multi', 'workflow_run_multi', 'workflow_list',
45
+ // meta-tools reserved by the framework
46
+ 'explore', 'plan', 'verify', 'debug', 'refactor', 'advise', 'delegate',
47
+ 'bahulam_info', 'remember', 'web_search', 'web_fetch',
48
+ ]);
49
+
50
+ /**
51
+ * Preflight a plugin at `pluginDir`.
52
+ * @param {string} pluginDir absolute path to the plugin directory
53
+ * @param {object} [opts]
54
+ * @param {Function} [opts.existingPluginNames] () => Iterable<string>
55
+ * used to detect collisions with already-installed plugins
56
+ * @returns {Promise<{ok:boolean, errors:string[], warnings:string[], manifest:object|null}>}
57
+ */
58
+ export async function preflightPlugin(pluginDir, opts = {}) {
59
+ const errors = [];
60
+ const warnings = [];
61
+
62
+ const yamlPath = path.join(pluginDir, 'plugin.yaml');
63
+ const jsonPath = path.join(pluginDir, 'plugin.json');
64
+ const manifestPath = fs.existsSync(yamlPath) ? yamlPath
65
+ : fs.existsSync(jsonPath) ? jsonPath : null;
66
+ if (!manifestPath) {
67
+ return { ok: false, errors: [`No plugin.yaml or plugin.json in ${pluginDir}`], warnings, manifest: null };
68
+ }
69
+
70
+ const manifest = parsePluginManifestFile(manifestPath);
71
+ if (!manifest) {
72
+ return { ok: false, errors: [`Failed to parse manifest ${manifestPath}`], warnings, manifest: null };
73
+ }
74
+
75
+ // 1. Manifest schema
76
+ const schema = validatePluginManifest(manifest);
77
+ if (!schema.valid) errors.push(...schema.errors);
78
+
79
+ const name = manifest.metadata?.name || '';
80
+ if (name && !/^[a-z][a-z0-9-]{1,63}$/.test(name.toLowerCase())) {
81
+ warnings.push(`metadata.name "${name}" should be lowercase kebab-case for registry compatibility`);
82
+ }
83
+
84
+ const tools = manifest.spec?.tools || [];
85
+ const agents = manifest.spec?.agents || [];
86
+ const views = manifest.spec?.workspace?.views || [];
87
+ const mcpServers = manifest.spec?.mcpServers || {};
88
+ const mcpServerNames = new Set(Object.keys(mcpServers));
89
+
90
+ // MCP server sanity — every server should have EITHER command (stdio)
91
+ // OR url (remote). Anything else is meaningless config.
92
+ for (const [name, cfg] of Object.entries(mcpServers)) {
93
+ if (!cfg.command && !cfg.url) {
94
+ errors.push(`MCP server "${name}": needs either "command" (stdio) or "url" (remote)`);
95
+ }
96
+ if (cfg.command && cfg.url) {
97
+ warnings.push(`MCP server "${name}": has both command and url — command wins, url is ignored`);
98
+ }
99
+ if (cfg.args && !Array.isArray(cfg.args)) {
100
+ errors.push(`MCP server "${name}": args must be an array`);
101
+ }
102
+ }
103
+
104
+ // 2 + 3 + 4. Tool checks
105
+ const toolNames = new Set();
106
+ for (const [i, tool] of tools.entries()) {
107
+ const t = tool.name || `<tool #${i}>`;
108
+ if (!tool.name) { errors.push(`Tool #${i}: missing name`); continue; }
109
+ if (!TOOL_NAME_RE.test(tool.name)) {
110
+ errors.push(`Tool "${t}": name must match ${TOOL_NAME_RE} (letters, digits, _, -; ≤64 chars)`);
111
+ }
112
+ if (toolNames.has(tool.name)) errors.push(`Tool "${t}": duplicate name`);
113
+ toolNames.add(tool.name);
114
+ if (RESERVED_TOOL_NAMES.has(tool.name)) {
115
+ errors.push(`Tool "${t}": shadows a built-in tool — pick a different name (built-ins always win)`);
116
+ }
117
+ if (!tool.description || tool.description.length < 8) {
118
+ warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
119
+ }
120
+ if (!tool.tool) { errors.push(`Tool "${t}": missing tool module path (tool: ./tools/<name>.mjs)`); continue; }
121
+
122
+ const toolModulePath = path.resolve(pluginDir, tool.tool);
123
+ // Traversal guard
124
+ const inside = toolModulePath === pluginDir || toolModulePath.startsWith(pluginDir + path.sep);
125
+ if (!inside) errors.push(`Tool "${t}": tool module path escapes the plugin directory`);
126
+ else if (!fs.existsSync(toolModulePath)) errors.push(`Tool "${t}": tool module not found: ${tool.tool}`);
127
+ else {
128
+ try {
129
+ // Cache-bust because a previous install may have imported an older
130
+ // copy at the same path in this process.
131
+ const mod = await import(`${pathToFileURL(toolModulePath).href}?preflight=${Date.now()}`);
132
+ if (typeof mod.call !== 'function') {
133
+ errors.push(`Tool "${t}": tool module ${tool.tool} does not export an async \`call\` function`);
134
+ }
135
+ } catch (err) {
136
+ errors.push(`Tool "${t}": tool module ${tool.tool} failed to import: ${err.message}`);
137
+ }
138
+ }
139
+
140
+ // JSON-Schema shape (very light — reject non-objects, missing type: object)
141
+ const params = tool.input_schema || tool.parameters;
142
+ if (params && typeof params !== 'object') {
143
+ errors.push(`Tool "${t}": parameters/input_schema must be an object`);
144
+ } else if (params && params.type && params.type !== 'object') {
145
+ warnings.push(`Tool "${t}": parameters.type should be "object" for tool_use compatibility`);
146
+ }
147
+ }
148
+
149
+ // 5. Sub-agent checks
150
+ const agentSlugs = new Set();
151
+ for (const [i, agent] of agents.entries()) {
152
+ const slug = agent.slug || agent.name || `<agent #${i}>`;
153
+ if (!agent.slug && !agent.name) { errors.push(`Agent #${i}: missing slug or name`); continue; }
154
+ if (agent.slug && !AGENT_SLUG_RE.test(agent.slug)) {
155
+ errors.push(`Agent "${slug}": slug must match ${AGENT_SLUG_RE} (lowercase kebab, ≤64 chars)`);
156
+ }
157
+ if (agentSlugs.has(slug)) errors.push(`Agent "${slug}": duplicate slug`);
158
+ agentSlugs.add(slug);
159
+ if (!agent.system_prompt) warnings.push(`Agent "${slug}": missing system_prompt`);
160
+
161
+ for (const toolRef of (agent.tools || [])) {
162
+ if (typeof toolRef !== 'string' || !toolRef.trim()) continue;
163
+ // MCP tools appear as `<server>.<tool>`. We can't spawn the
164
+ // server at preflight time (would need network/subprocess), so
165
+ // we only check that the <server> half is declared under this
166
+ // plugin's mcpServers. The <tool> half is discovered live.
167
+ if (toolRef.includes('.')) {
168
+ const serverName = toolRef.split('.', 1)[0];
169
+ if (!mcpServerNames.has(serverName)) {
170
+ errors.push(`Agent "${slug}": tool "${toolRef}" references MCP server "${serverName}" which is not declared in mcpServers`);
171
+ }
172
+ continue;
173
+ }
174
+ if (!toolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
175
+ errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
176
+ }
177
+ }
178
+ }
179
+
180
+ // 6. Workspace views
181
+ const viewNames = new Set();
182
+ for (const [i, view] of views.entries()) {
183
+ const label = view.name || `<view #${i}>`;
184
+ if (viewNames.has(label)) warnings.push(`View "${label}": duplicate name — tabs will collide`);
185
+ viewNames.add(label);
186
+ const source = String(view.source || '').trim();
187
+ if (!source) { errors.push(`View "${label}": missing source`); continue; }
188
+ const abs = path.resolve(pluginDir, source);
189
+ const inside = abs === pluginDir || abs.startsWith(pluginDir + path.sep);
190
+ if (!inside) errors.push(`View "${label}": source path escapes the plugin directory`);
191
+ else if (!fs.existsSync(abs)) errors.push(`View "${label}": source file not found: ${source}`);
192
+ else if (!/\.(html?|htm)$/i.test(source)) warnings.push(`View "${label}": source should be an .html file`);
193
+ }
194
+
195
+ // 7. Install collision
196
+ const existing = new Set(
197
+ Array.from(opts.existingPluginNames?.() || [])
198
+ .map(n => String(n || '').toLowerCase())
199
+ );
200
+ existing.delete(name.toLowerCase()); // reinstall of the same plugin is fine
201
+ if (existing.has(name.toLowerCase())) {
202
+ errors.push(`A different plugin already claims the name "${name}" (use --force to overwrite)`);
203
+ }
204
+
205
+ return { ok: errors.length === 0, errors, warnings, manifest };
206
+ }
207
+
208
+ /**
209
+ * Convenience — collect installed plugin names from both search paths.
210
+ * Used by the installer to detect collisions.
211
+ */
212
+ export function existingInstalledNames(cwd = process.cwd()) {
213
+ const names = [];
214
+ for (const dir of [
215
+ path.join(cwd, '.bahulam', 'plugins'),
216
+ path.join(os.homedir(), '.bahulam', 'plugins'),
217
+ ]) {
218
+ if (!fs.existsSync(dir)) continue;
219
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
220
+ if (!entry.isDirectory() || entry.name.endsWith('.disabled')) continue;
221
+ const m = parsePluginManifestFile(path.join(dir, entry.name, 'plugin.yaml'))
222
+ || parsePluginManifestFile(path.join(dir, entry.name, 'plugin.json'));
223
+ if (m?.metadata?.name) names.push(m.metadata.name);
224
+ }
225
+ }
226
+ return names;
227
+ }