@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,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
|
+
}
|