@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.
- package/package.json +5 -2
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/env.mjs +2 -0
- package/src/config/hook-runner.mjs +8 -8
- package/src/config/memory-loader.mjs +7 -3
- package/src/config/settings-loader.mjs +5 -3
- package/src/core/attachments.mjs +2 -2
- package/src/core/headless.mjs +6 -1
- package/src/core/local-store.mjs +10 -10
- package/src/core/paths.mjs +10 -96
- package/src/core/policy-resolver.mjs +1 -1
- package/src/core/project-context-loader.mjs +2 -2
- package/src/core/stream-client.mjs +68 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +251 -10
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +230 -7
- package/src/plugins/executor.mjs +121 -0
- package/src/plugins/loader.mjs +123 -123
- package/src/plugins/manifest.mjs +291 -0
- package/src/plugins/preflight.mjs +227 -0
- package/src/plugins/registry.mjs +233 -0
- package/src/plugins/state.mjs +290 -0
- package/src/terminal/agents.mjs +18 -1
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +75 -2
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +2 -2
- package/src/terminal/repl.mjs +49 -3
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/ui/slash-commands.mjs +18 -0
|
@@ -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
|
+
}
|
package/src/plugins/loader.mjs
CHANGED
|
@@ -1,138 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin Loader —
|
|
2
|
+
* Plugin Loader — high-level facade for installing, listing, and removing plugins.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Plugin format: a directory with
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|