@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.
- package/package.json +5 -2
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/cli-args.mjs +16 -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/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +59 -3
- package/src/core/local-agent.mjs +10 -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/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +148 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +457 -12
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +345 -20
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- 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 +26 -4
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +83 -4
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +67 -12
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +621 -99
- package/src/tools/agent.mjs +6 -2
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/tools/registry.mjs +88 -4
- package/src/ui/slash-commands.mjs +19 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { backgroundTasks } from '../core/background-tasks.mjs';
|
|
3
|
+
import { normalizeGraphSpec, validateGraph } from './graph.mjs';
|
|
4
|
+
import { deriveApprovalScope } from './approval.mjs';
|
|
5
|
+
import { runNode } from './node-runner.mjs';
|
|
6
|
+
import { graphEvent } from './events.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a graph of agent nodes locally, in-process. Yields the same
|
|
10
|
+
* event vocabulary the main renderer already consumes, plus lifecycle
|
|
11
|
+
* events: graph_run_start, graph_node_start, graph_node_result,
|
|
12
|
+
* graph_run_result.
|
|
13
|
+
*
|
|
14
|
+
* pattern: 'sequential' runs nodes one at a time in topological order.
|
|
15
|
+
* pattern: 'parallel' groups nodes into dependency levels — nodes in the
|
|
16
|
+
* same level (no path between them) execute concurrently; their events
|
|
17
|
+
* are buffered and flushed contiguously in topological order so the
|
|
18
|
+
* output stays deterministic while wall-clock shrinks. Handoffs are
|
|
19
|
+
* snapshotted at level start: same-level nodes never see each other,
|
|
20
|
+
* only completed upstream levels.
|
|
21
|
+
*/
|
|
22
|
+
export async function* runGraph(graphSpec, triggerInput = {}, ctx = {}, options = {}) {
|
|
23
|
+
const spec = normalizeGraphSpec(graphSpec);
|
|
24
|
+
const agentNodes = validateGraph(spec);
|
|
25
|
+
const runId = options.runId || `gr-${crypto.randomUUID().slice(0, 12)}`;
|
|
26
|
+
|
|
27
|
+
const resolveAgent = ctx.resolveAgent || (() => null);
|
|
28
|
+
for (const node of agentNodes) {
|
|
29
|
+
if (node.type === 'job' || node.type === 'service') continue;
|
|
30
|
+
if (!node.agent) node.agent = resolveAgent(node.agent_slug);
|
|
31
|
+
if (!node.agent) throw new Error(`Unknown agent '${node.agent_slug}' in graph '${spec.name}'`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const approvalScope = deriveApprovalScope(agentNodes, resolveAgent);
|
|
35
|
+
yield graphEvent('graph_run_start', runId, {
|
|
36
|
+
graph: spec.name,
|
|
37
|
+
pattern: spec.pattern,
|
|
38
|
+
node_count: agentNodes.length,
|
|
39
|
+
approval_scope: approvalScope,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const levels = spec.pattern === 'parallel'
|
|
43
|
+
? dependencyLevels(agentNodes, spec.edges)
|
|
44
|
+
: agentNodes.map(node => [node]);
|
|
45
|
+
|
|
46
|
+
const nodeResults = [];
|
|
47
|
+
const handoffs = [];
|
|
48
|
+
let status = 'completed';
|
|
49
|
+
// Service nodes register their process here; the run is their lifetime
|
|
50
|
+
// scope — everything still running is killed when the graph ends,
|
|
51
|
+
// whatever path it ends by (finally covers errors and early consumer
|
|
52
|
+
// abandonment too).
|
|
53
|
+
const serviceJobs = [];
|
|
54
|
+
const nodeOptions = { ...options, runId, serviceJobs };
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
for (const level of levels) {
|
|
58
|
+
if (status === 'failed') break;
|
|
59
|
+
const levelHandoffs = [...handoffs];
|
|
60
|
+
|
|
61
|
+
for (const node of level) {
|
|
62
|
+
yield graphEvent('graph_node_start', runId, {
|
|
63
|
+
node_id: node.id,
|
|
64
|
+
agent: node.agent_slug || node.id,
|
|
65
|
+
parallel_group: level.length > 1 ? level.map(n => n.id) : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Solo level: stream events live (no buffering) — this is the /run
|
|
70
|
+
// and sequential-pattern hot path.
|
|
71
|
+
if (level.length === 1) {
|
|
72
|
+
const node = level[0];
|
|
73
|
+
const instruction = buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params);
|
|
74
|
+
let output = '';
|
|
75
|
+
let accumulated = '';
|
|
76
|
+
let nodeStatus = 'completed';
|
|
77
|
+
try {
|
|
78
|
+
for await (const event of runNode(node, node.agent, instruction, ctx, nodeOptions)) {
|
|
79
|
+
if (event.type === 'content' || event.type === 'content_partial') {
|
|
80
|
+
accumulated += event.data?.text || event.data?.content || '';
|
|
81
|
+
}
|
|
82
|
+
if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
|
|
83
|
+
output = event.data.final_response;
|
|
84
|
+
}
|
|
85
|
+
yield event;
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
nodeStatus = 'failed';
|
|
89
|
+
output = err?.message || String(err);
|
|
90
|
+
}
|
|
91
|
+
if (!output && nodeStatus === 'completed') output = accumulated;
|
|
92
|
+
const result = { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output };
|
|
93
|
+
nodeResults.push(result);
|
|
94
|
+
yield graphEvent('graph_node_result', runId, result);
|
|
95
|
+
if (result.status === 'failed' && !node.continue_on_error) {
|
|
96
|
+
status = 'failed';
|
|
97
|
+
} else if (result.status === 'completed' && result.output) {
|
|
98
|
+
handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parallel level: start every node concurrently; drain buffers in
|
|
104
|
+
// topological order so events stay contiguous per node.
|
|
105
|
+
const runs = level.map(node => collectNodeRun(
|
|
106
|
+
node,
|
|
107
|
+
buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params),
|
|
108
|
+
ctx,
|
|
109
|
+
nodeOptions,
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < level.length; i++) {
|
|
113
|
+
const { events, result } = await runs[i];
|
|
114
|
+
for (const event of events) yield event;
|
|
115
|
+
nodeResults.push(result);
|
|
116
|
+
yield graphEvent('graph_node_result', runId, result);
|
|
117
|
+
if (result.status === 'failed' && !level[i].continue_on_error) {
|
|
118
|
+
status = 'failed';
|
|
119
|
+
} else if (result.status === 'completed' && result.output) {
|
|
120
|
+
handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
} finally {
|
|
126
|
+
for (const id of serviceJobs) {
|
|
127
|
+
try { backgroundTasks.kill(id); } catch { /* already gone */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = {
|
|
132
|
+
run_id: runId,
|
|
133
|
+
graph: spec.name,
|
|
134
|
+
status,
|
|
135
|
+
node_results: nodeResults,
|
|
136
|
+
output: nodeResults.length ? nodeResults[nodeResults.length - 1].output : '',
|
|
137
|
+
};
|
|
138
|
+
yield graphEvent('graph_run_result', runId, result);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function collectNodeRun(node, instruction, ctx, options) {
|
|
143
|
+
const events = [];
|
|
144
|
+
let output = '';
|
|
145
|
+
let accumulated = '';
|
|
146
|
+
let nodeStatus = 'completed';
|
|
147
|
+
try {
|
|
148
|
+
for await (const event of runNode(node, node.agent, instruction, ctx, options)) {
|
|
149
|
+
if (event.type === 'content' || event.type === 'content_partial') {
|
|
150
|
+
accumulated += event.data?.text || event.data?.content || '';
|
|
151
|
+
}
|
|
152
|
+
if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
|
|
153
|
+
output = event.data.final_response;
|
|
154
|
+
}
|
|
155
|
+
events.push(event);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
nodeStatus = 'failed';
|
|
159
|
+
output = err?.message || String(err);
|
|
160
|
+
}
|
|
161
|
+
if (!output && nodeStatus === 'completed') output = accumulated;
|
|
162
|
+
return {
|
|
163
|
+
events,
|
|
164
|
+
result: { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Group agent nodes by dependency depth: level N nodes depend only on
|
|
169
|
+
// nodes in levels < N. agentNodes arrive topologically ordered, so each
|
|
170
|
+
// node's predecessors are resolved before it.
|
|
171
|
+
function dependencyLevels(agentNodes, edges) {
|
|
172
|
+
const ids = new Set(agentNodes.map(node => node.id));
|
|
173
|
+
const preds = new Map(agentNodes.map(node => [node.id, []]));
|
|
174
|
+
for (const edge of edges) {
|
|
175
|
+
if (ids.has(edge.source) && ids.has(edge.target)) preds.get(edge.target).push(edge.source);
|
|
176
|
+
}
|
|
177
|
+
const depth = new Map();
|
|
178
|
+
const levels = [];
|
|
179
|
+
for (const node of agentNodes) {
|
|
180
|
+
const upstream = preds.get(node.id);
|
|
181
|
+
const d = upstream.length ? 1 + Math.max(...upstream.map(id => depth.get(id) ?? 0)) : 0;
|
|
182
|
+
depth.set(node.id, d);
|
|
183
|
+
(levels[d] ||= []).push(node);
|
|
184
|
+
}
|
|
185
|
+
return levels;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildNodeInstruction(node, triggerInput, handoffs, globalParams) {
|
|
189
|
+
const parts = [node.prompt || triggerInput.instruction || ''];
|
|
190
|
+
if (globalParams && Object.keys(globalParams).length) {
|
|
191
|
+
parts.push(`Parameters:\n${JSON.stringify(globalParams, null, 2)}`);
|
|
192
|
+
}
|
|
193
|
+
if (handoffs.length) {
|
|
194
|
+
const upstream = handoffs
|
|
195
|
+
.map(h => `--- handoff from ${h.agent} ---\n${h.output}`)
|
|
196
|
+
.join('\n\n');
|
|
197
|
+
parts.push(`Results from earlier steps:\n\n${upstream}`);
|
|
198
|
+
}
|
|
199
|
+
return parts.filter(Boolean).join('\n\n');
|
|
200
|
+
}
|
|
@@ -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.tool) continue;
|
|
77
|
+
const handler = await loadPluginTool(pluginDir, toolDef.tool);
|
|
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
|
+
}
|