@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,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.handler) { errors.push(`Tool "${t}": missing handler path`); continue; }
121
+
122
+ const handlerPath = path.resolve(pluginDir, tool.handler);
123
+ // Traversal guard
124
+ const inside = handlerPath === pluginDir || handlerPath.startsWith(pluginDir + path.sep);
125
+ if (!inside) errors.push(`Tool "${t}": handler path escapes the plugin directory`);
126
+ else if (!fs.existsSync(handlerPath)) errors.push(`Tool "${t}": handler file not found: ${tool.handler}`);
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(handlerPath).href}?preflight=${Date.now()}`);
132
+ if (typeof mod.call !== 'function') {
133
+ errors.push(`Tool "${t}": handler ${tool.handler} does not export an async \`call\` function`);
134
+ }
135
+ } catch (err) {
136
+ errors.push(`Tool "${t}": handler ${tool.handler} 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
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Plugin Registry — scan, load, validate, and deduplicate plugin manifests.
3
+ *
4
+ * Scans standard directories for plugin.yaml / plugin.json manifests.
5
+ * Follows the same pattern as AgentLoader and SkillsLoader.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
12
+
13
+ const DEFAULT_PLUGIN_DIRS = () => [
14
+ path.join(process.cwd(), '.bahulam', 'plugins'),
15
+ path.join(os.homedir(), '.bahulam', 'plugins'),
16
+ ];
17
+
18
+ export class PluginRegistry {
19
+ /**
20
+ * @param {Object} [options]
21
+ * @param {string[]} [options.pluginDirs] - Directories to scan (default: project .bahulam/plugins + ~/.bahulam/plugins)
22
+ * @param {string[]} [options.disabled] - Plugin names to skip
23
+ * @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
24
+ * @param {string[]} [options.active] - Alias for enabled
25
+ * @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
26
+ */
27
+ constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
28
+ this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : DEFAULT_PLUGIN_DIRS());
29
+ this.disabled = new Set(
30
+ (Array.isArray(disabled) ? disabled : [])
31
+ .map(s => String(s).trim().toLowerCase())
32
+ .filter(Boolean),
33
+ );
34
+ const enabledList = Array.isArray(enabled) ? enabled : (Array.isArray(active) ? active : []);
35
+ this.enabled = new Set(
36
+ enabledList
37
+ .map(s => String(s).trim().toLowerCase())
38
+ .filter(Boolean),
39
+ );
40
+ this.plugins = new Map(); // name → manifest
41
+ this.errors = []; // { name, message }
42
+ }
43
+
44
+ /**
45
+ * Scan all plugin directories and load manifests.
46
+ * @returns {this}
47
+ */
48
+ scan() {
49
+ for (const dir of this.pluginDirs) {
50
+ this._scanDir(dir);
51
+ }
52
+ return this;
53
+ }
54
+
55
+ _scanDir(dir) {
56
+ try {
57
+ if (!fs.existsSync(dir)) return;
58
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ if (!entry.isDirectory()) continue;
61
+ const pluginDir = path.join(dir, entry.name);
62
+
63
+ // Try plugin.yaml, plugin.json (in that order)
64
+ let manifestPath = path.join(pluginDir, 'plugin.yaml');
65
+ if (!fs.existsSync(manifestPath)) {
66
+ manifestPath = path.join(pluginDir, 'plugin.json');
67
+ if (!fs.existsSync(manifestPath)) continue;
68
+ }
69
+
70
+ const manifest = parsePluginManifestFile(manifestPath);
71
+ if (!manifest) {
72
+ this.errors.push({
73
+ plugin: entry.name,
74
+ message: `Failed to parse manifest: ${manifestPath}`,
75
+ });
76
+ continue;
77
+ }
78
+
79
+ this.register(manifest);
80
+ }
81
+ } catch (err) {
82
+ if (process.env.DEBUG) {
83
+ console.error(`Plugin registry scan error in ${dir}: ${err.message}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Register a plugin manifest.
90
+ * @param {object} manifest - Normalized manifest from normalizeManifest()
91
+ * @returns {boolean} true if registered, false if skipped (disabled or duplicate)
92
+ */
93
+ register(manifest) {
94
+ const name = manifest.metadata?.name || '';
95
+ if (!name) return false;
96
+
97
+ const lowerName = name.toLowerCase();
98
+ const aliases = [
99
+ lowerName,
100
+ manifest._dir ? path.basename(manifest._dir).toLowerCase() : '',
101
+ ].filter(Boolean);
102
+
103
+ if (this.enabled.size > 0 && !aliases.some(alias => this.enabled.has(alias))) {
104
+ return false;
105
+ }
106
+
107
+ // Check disabled list
108
+ if (aliases.some(alias => this.disabled.has(alias))) {
109
+ if (process.env.DEBUG) {
110
+ console.warn(`Plugin "${name}" is disabled, skipping`);
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Check for existing (first wins — project overrides global)
116
+ if (this.plugins.has(lowerName)) {
117
+ return false; // silently skip duplicates
118
+ }
119
+
120
+ // Validate
121
+ const { valid, errors } = validatePluginManifest(manifest);
122
+ if (!valid) {
123
+ this.errors.push({ plugin: name, message: errors.join('; ') });
124
+ return false;
125
+ }
126
+
127
+ this.plugins.set(lowerName, manifest);
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Get a plugin by name.
133
+ * @param {string} name
134
+ * @returns {object|null}
135
+ */
136
+ get(name) {
137
+ return this.plugins.get(String(name || '').toLowerCase()) || null;
138
+ }
139
+
140
+ /**
141
+ * List all registered plugins.
142
+ * @returns {object[]}
143
+ */
144
+ list() {
145
+ return [...this.plugins.values()];
146
+ }
147
+
148
+ /**
149
+ * List all tools from all plugins.
150
+ * @returns {object[]}
151
+ */
152
+ listTools() {
153
+ const tools = [];
154
+ for (const plugin of this.plugins.values()) {
155
+ for (const tool of (plugin.spec?.tools || [])) {
156
+ tools.push({
157
+ ...tool,
158
+ _plugin_name: plugin.metadata?.name,
159
+ _plugin_dir: plugin._dir,
160
+ });
161
+ }
162
+ }
163
+ return tools;
164
+ }
165
+
166
+ /**
167
+ * List every plugin-declared MCP server across the registry.
168
+ *
169
+ * Each entry is [{plugin, name, config}] where `config` is a
170
+ * Claude-Desktop-compatible object (command/args/env or url/headers).
171
+ * Consumers spawn one McpClient per entry at session start; the
172
+ * server's tools are then namespaced as `<name>.<tool>` in the
173
+ * tool executor so two plugins can ship servers with the same tool
174
+ * name without collision.
175
+ * @returns {{plugin: string, name: string, config: object}[]}
176
+ */
177
+ listMcpServers() {
178
+ const out = [];
179
+ for (const plugin of this.plugins.values()) {
180
+ const servers = plugin.spec?.mcpServers || {};
181
+ const pluginName = plugin.metadata?.name || '';
182
+ for (const [name, config] of Object.entries(servers)) {
183
+ if (config && typeof config === 'object') {
184
+ out.push({ plugin: pluginName, name, config });
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * List all agents from all plugins.
193
+ * @returns {object[]}
194
+ */
195
+ listAgents() {
196
+ const agents = [];
197
+ for (const plugin of this.plugins.values()) {
198
+ for (const agent of (plugin.spec?.agents || [])) {
199
+ agents.push({
200
+ ...agent,
201
+ _plugin_name: plugin.metadata?.name,
202
+ });
203
+ }
204
+ }
205
+ return agents;
206
+ }
207
+
208
+ /**
209
+ * Check if a plugin exists.
210
+ * @param {string} name
211
+ * @returns {boolean}
212
+ */
213
+ has(name) {
214
+ return this.plugins.has(String(name || '').toLowerCase());
215
+ }
216
+
217
+ /**
218
+ * Remove a plugin by name.
219
+ * @param {string} name
220
+ * @returns {boolean}
221
+ */
222
+ remove(name) {
223
+ return this.plugins.delete(String(name || '').toLowerCase());
224
+ }
225
+
226
+ /**
227
+ * Get plugin count.
228
+ * @returns {number}
229
+ */
230
+ count() {
231
+ return this.plugins.size;
232
+ }
233
+ }