@bahulam/code 0.1.21 → 0.1.23
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 +1 -1
- package/src/agents/loader.mjs +1 -0
- package/src/agents/parser.mjs +1 -0
- package/src/agents/registry.mjs +5 -1
- package/src/commands/mcp.mjs +296 -0
- package/src/commands/plugin-manage.mjs +31 -20
- package/src/commands/plugin.mjs +3 -6
- package/src/core/headless.mjs +9 -0
- package/src/core/paths.mjs +24 -0
- package/src/core/stream-client.mjs +48 -12
- package/src/core/tool-executor.mjs +133 -11
- package/src/local-service/server.mjs +1 -4
- package/src/mcp/client.mjs +54 -5
- package/src/mcp/loader.mjs +98 -0
- package/src/mcp/transport-shttp.mjs +2 -1
- package/src/plugins/manifest.mjs +207 -1
- package/src/plugins/pi-compat/requirements.mjs +67 -1
- package/src/plugins/pi-compat/scaffold.mjs +209 -4
- package/src/plugins/preflight.mjs +28 -7
- package/src/plugins/registry.mjs +12 -9
- package/src/plugins/state-tools.mjs +86 -0
- package/src/plugins/state.mjs +168 -16
- package/src/terminal/main.mjs +9 -0
- package/src/terminal/repl.mjs +40 -0
- package/src/tools/registry.mjs +7 -1
- package/src/ui/commands.mjs +35 -10
|
@@ -22,7 +22,8 @@ import * as path from 'node:path';
|
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
23
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
24
24
|
import { composedToolName, validateCompose } from './pi-compose.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { expandStateContextTools } from './state-tools.mjs';
|
|
26
|
+
import { pluginDirs } from '../core/paths.mjs';
|
|
26
27
|
|
|
27
28
|
const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
28
29
|
const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
@@ -182,6 +183,25 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// 4b. Generated state tools. A plugin that declares config.state.context_tools
|
|
187
|
+
// gets read-only tools synthesized at load time (state-tools.mjs), so an
|
|
188
|
+
// agent may legitimately allowlist them. Without this, a plugin referencing
|
|
189
|
+
// its own generated tool fails preflight as "not defined by this plugin".
|
|
190
|
+
const stateToolNames = new Set();
|
|
191
|
+
for (const generated of expandStateContextTools(name, pluginDir, manifest.config?.state)) {
|
|
192
|
+
const gname = generated.name;
|
|
193
|
+
if (!gname) continue;
|
|
194
|
+
if (toolNames.has(gname) || composedToolNames.has(gname)) {
|
|
195
|
+
errors.push(`Generated state tool "${gname}" collides with an existing tool of the same name`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (RESERVED_TOOL_NAMES.has(gname)) {
|
|
199
|
+
errors.push(`Generated state tool "${gname}" shadows a built-in tool — rename it in config.state.context_tools`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
stateToolNames.add(gname);
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
// 5. Sub-agent checks
|
|
186
206
|
const agentSlugs = new Set();
|
|
187
207
|
for (const [i, agent] of agents.entries()) {
|
|
@@ -207,7 +227,8 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
207
227
|
}
|
|
208
228
|
continue;
|
|
209
229
|
}
|
|
210
|
-
if (!toolNames.has(toolRef) && !
|
|
230
|
+
if (!toolNames.has(toolRef) && !stateToolNames.has(toolRef)
|
|
231
|
+
&& !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
|
|
211
232
|
errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
|
|
212
233
|
}
|
|
213
234
|
}
|
|
@@ -245,12 +266,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
245
266
|
* Convenience — collect installed plugin names from both search paths.
|
|
246
267
|
* Used by the installer to detect collisions.
|
|
247
268
|
*/
|
|
248
|
-
export function existingInstalledNames(
|
|
269
|
+
export function existingInstalledNames(_cwd = process.cwd()) {
|
|
249
270
|
const names = [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
271
|
+
// Plugins live in one global root. `cwd` is kept only so existing call
|
|
272
|
+
// sites keep working; it must never influence discovery, since that
|
|
273
|
+
// cwd-dependence is the thing this removed.
|
|
274
|
+
for (const dir of pluginDirs()) {
|
|
254
275
|
if (!fs.existsSync(dir)) continue;
|
|
255
276
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
256
277
|
if (!entry.isDirectory() || entry.name.endsWith('.disabled')) continue;
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -9,24 +9,20 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
11
11
|
import { expandComposedTools } from './pi-compose.mjs';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const DEFAULT_PLUGIN_DIRS = () => [
|
|
15
|
-
path.join(process.cwd(), '.bahulam', 'plugins'),
|
|
16
|
-
path.join(bahulamHome(), 'plugins'),
|
|
17
|
-
];
|
|
12
|
+
import { expandStateContextTools } from './state-tools.mjs';
|
|
13
|
+
import { pluginDirs as defaultPluginDirs } from '../core/paths.mjs';
|
|
18
14
|
|
|
19
15
|
export class PluginRegistry {
|
|
20
16
|
/**
|
|
21
17
|
* @param {Object} [options]
|
|
22
|
-
* @param {string[]} [options.pluginDirs] - Directories to scan (default:
|
|
18
|
+
* @param {string[]} [options.pluginDirs] - Directories to scan (default: ~/.bahulam/plugins)
|
|
23
19
|
* @param {string[]} [options.disabled] - Plugin names to skip
|
|
24
20
|
* @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
|
|
25
21
|
* @param {string[]} [options.active] - Alias for enabled
|
|
26
22
|
* @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
|
|
27
23
|
*/
|
|
28
24
|
constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
|
|
29
|
-
this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] :
|
|
25
|
+
this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : defaultPluginDirs());
|
|
30
26
|
this.disabled = new Set(
|
|
31
27
|
(Array.isArray(disabled) ? disabled : [])
|
|
32
28
|
.map(s => String(s).trim().toLowerCase())
|
|
@@ -113,7 +109,7 @@ export class PluginRegistry {
|
|
|
113
109
|
return false;
|
|
114
110
|
}
|
|
115
111
|
|
|
116
|
-
//
|
|
112
|
+
// Duplicates are skipped — the first manifest scanned wins.
|
|
117
113
|
if (this.plugins.has(lowerName)) {
|
|
118
114
|
return false; // silently skip duplicates
|
|
119
115
|
}
|
|
@@ -165,6 +161,13 @@ export class PluginRegistry {
|
|
|
165
161
|
plugin._dir,
|
|
166
162
|
plugin.config?.composes || [],
|
|
167
163
|
));
|
|
164
|
+
// Manifest-declared state query tools. Synthesized rather than
|
|
165
|
+
// imported, so they carry `_state_tool` instead of a module path.
|
|
166
|
+
tools.push(...expandStateContextTools(
|
|
167
|
+
plugin.metadata?.name || '',
|
|
168
|
+
plugin._dir,
|
|
169
|
+
plugin.config?.state,
|
|
170
|
+
));
|
|
168
171
|
}
|
|
169
172
|
return tools;
|
|
170
173
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared-state tool expansion.
|
|
3
|
+
*
|
|
4
|
+
* A plugin that declares tables in `config.state.tables` can ask the CLI
|
|
5
|
+
* to generate read-only query tools for the agent, instead of hand-writing
|
|
6
|
+
* a `tools/*.mjs` handler for every "let me look at my data" case. The
|
|
7
|
+
* manifest block looks like:
|
|
8
|
+
*
|
|
9
|
+
* state:
|
|
10
|
+
* tables:
|
|
11
|
+
* - name: questions
|
|
12
|
+
* columns: [...]
|
|
13
|
+
* context_tools:
|
|
14
|
+
* - name: list_questions
|
|
15
|
+
* table: questions
|
|
16
|
+
* description: List exam questions, optionally filtered by topic
|
|
17
|
+
* parameters:
|
|
18
|
+
* type: object
|
|
19
|
+
* properties:
|
|
20
|
+
* topic: { type: string }
|
|
21
|
+
* where: "topic = ?"
|
|
22
|
+
* params: [topic]
|
|
23
|
+
*
|
|
24
|
+
* `expandStateContextTools` turns that into registry tool entries. The
|
|
25
|
+
* execution side (tool-executor) recognizes the `_state_tool` marker and
|
|
26
|
+
* runs the query against the plugin's own state DB — so the same tool
|
|
27
|
+
* flows through every existing surface: `listPluginToolSchemas()`,
|
|
28
|
+
* the client_tools map, sub-agent tool allowlists, and `/tools`.
|
|
29
|
+
*
|
|
30
|
+
* This mirrors `pi-compose.mjs`: one expansion per composition kind, both
|
|
31
|
+
* feeding the same `PluginRegistry.listTools()` contract.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Tool names are passed to the model, so keep them in the same charset the rest of the CLI uses. */
|
|
35
|
+
function sanitizeToolName(value) {
|
|
36
|
+
return String(value || '').trim().replace(/[^A-Za-z0-9_]/g, '_');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Expand a plugin's declared `config.state.context_tools` into tool entries.
|
|
41
|
+
*
|
|
42
|
+
* Entries carry no `tool:` module path — they are synthesized, and
|
|
43
|
+
* `_state_tool` tells the executor to query the plugin's state DB rather
|
|
44
|
+
* than import a handler file.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} pluginName
|
|
47
|
+
* @param {string} pluginDir
|
|
48
|
+
* @param {object|null|undefined} state normalized `config.state`
|
|
49
|
+
* @returns {object[]}
|
|
50
|
+
*/
|
|
51
|
+
export function expandStateContextTools(pluginName, pluginDir, state) {
|
|
52
|
+
const tools = [];
|
|
53
|
+
const declared = Array.isArray(state?.context_tools) ? state.context_tools : [];
|
|
54
|
+
if (!pluginName || declared.length === 0) return tools;
|
|
55
|
+
|
|
56
|
+
const tableNames = new Set((state?.tables || []).map(t => t.name));
|
|
57
|
+
|
|
58
|
+
for (const tool of declared) {
|
|
59
|
+
const name = sanitizeToolName(tool?.name);
|
|
60
|
+
if (!name) continue;
|
|
61
|
+
// A generated tool must never reach past the plugin's own declared
|
|
62
|
+
// tables — that is the whole safety story for author-supplied `where`.
|
|
63
|
+
if (!tableNames.has(tool.table)) continue;
|
|
64
|
+
|
|
65
|
+
tools.push({
|
|
66
|
+
name,
|
|
67
|
+
description: tool.description || `List rows from ${tool.table}`,
|
|
68
|
+
input_schema: tool.parameters || { type: 'object', properties: {} },
|
|
69
|
+
// No module to load — signals "synthesized" to any consumer that
|
|
70
|
+
// would otherwise try to resolve a handler file.
|
|
71
|
+
tool: null,
|
|
72
|
+
plugin_name: pluginName,
|
|
73
|
+
_plugin_name: pluginName,
|
|
74
|
+
_plugin_dir: pluginDir,
|
|
75
|
+
_state_tool: {
|
|
76
|
+
plugin: pluginName,
|
|
77
|
+
table: tool.table,
|
|
78
|
+
where: tool.where || '',
|
|
79
|
+
params: Array.isArray(tool.params) ? tool.params : [],
|
|
80
|
+
limit: tool.limit || 50,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return tools;
|
|
86
|
+
}
|
package/src/plugins/state.mjs
CHANGED
|
@@ -206,17 +206,18 @@ function pluginDataDir(pluginName) {
|
|
|
206
206
|
return dir;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
function openDb(pluginName) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
209
|
+
function openDb(pluginName, { tables = [] } = {}) {
|
|
210
|
+
let handle = _handles.get(pluginName);
|
|
211
|
+
if (!handle) {
|
|
212
|
+
const dir = pluginDataDir(pluginName);
|
|
213
|
+
const dbPath = path.join(dir, 'state.db');
|
|
214
|
+
const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
|
|
215
|
+
// WAL: multiple readers, one writer; robust against concurrent view+agent.
|
|
216
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
217
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
218
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
219
|
+
// Bootstrap schema — idempotent so evolving plugins never crash on start.
|
|
220
|
+
db.exec(`
|
|
220
221
|
CREATE TABLE IF NOT EXISTS kv (
|
|
221
222
|
key TEXT PRIMARY KEY,
|
|
222
223
|
value TEXT NOT NULL,
|
|
@@ -231,11 +232,99 @@ function openDb(pluginName) {
|
|
|
231
232
|
CREATE INDEX IF NOT EXISTS records_stream_idx
|
|
232
233
|
ON records(stream, id DESC);
|
|
233
234
|
`);
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
handle = { db, dir, path: dbPath, schemaSig: null, declaredTables: new Set() };
|
|
236
|
+
_handles.set(pluginName, handle);
|
|
237
|
+
}
|
|
238
|
+
// Declared tables are applied on every open call, not just the first —
|
|
239
|
+
// a plugin installed mid-session (or a version bump that adds columns)
|
|
240
|
+
// must still converge the DB. The signature check makes the repeat
|
|
241
|
+
// calls free once the schema already matches.
|
|
242
|
+
applyDeclaredSchema(handle, tables);
|
|
236
243
|
return handle;
|
|
237
244
|
}
|
|
238
245
|
|
|
246
|
+
function quoteIdent(name) {
|
|
247
|
+
return `"${String(name).replace(/"/g, '""')}"`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Render one normalized column as the DDL fragment it contributes. */
|
|
251
|
+
function columnToSql(col) {
|
|
252
|
+
const parts = [quoteIdent(col.name), col.type];
|
|
253
|
+
if (col.primary) parts.push('PRIMARY KEY');
|
|
254
|
+
if (col.autoincrement) parts.push('AUTOINCREMENT');
|
|
255
|
+
if (col.not_null) parts.push('NOT NULL');
|
|
256
|
+
// `default` is author-supplied SQL text (e.g. "'medium'", "0"), which is
|
|
257
|
+
// why normalizeState stringifies it and why it is never parameterized.
|
|
258
|
+
if (col.default != null) parts.push(`DEFAULT ${col.default}`);
|
|
259
|
+
if (col.references) parts.push(`REFERENCES ${col.references}`);
|
|
260
|
+
return parts.join(' ');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create/evolve the tables a plugin declared in `config.state.tables`.
|
|
265
|
+
*
|
|
266
|
+
* Additive by contract: a table that exists is left alone apart from
|
|
267
|
+
* genuinely new columns, which are appended with ALTER TABLE. Nothing is
|
|
268
|
+
* ever dropped or retyped, so bumping a plugin version can't cost the
|
|
269
|
+
* user their data. Re-running with an unchanged declaration is a no-op.
|
|
270
|
+
*
|
|
271
|
+
* Silently does nothing on the JSON fallback backend, where arbitrary SQL
|
|
272
|
+
* isn't available — plugins that need declared tables need node:sqlite.
|
|
273
|
+
*/
|
|
274
|
+
function applyDeclaredSchema(handle, tables) {
|
|
275
|
+
if (!DatabaseSync) return;
|
|
276
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
277
|
+
handle.declaredTables = new Set();
|
|
278
|
+
handle.schemaSig = null;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const sig = JSON.stringify(tables);
|
|
282
|
+
if (handle.schemaSig === sig) return;
|
|
283
|
+
|
|
284
|
+
const { db } = handle;
|
|
285
|
+
for (const table of tables) {
|
|
286
|
+
let existing = new Set();
|
|
287
|
+
try {
|
|
288
|
+
existing = new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`).all().map(r => String(r.name)));
|
|
289
|
+
} catch { /* table doesn't exist yet — created below */ }
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
if (existing.size === 0) {
|
|
293
|
+
const defs = table.columns.map(columnToSql).join(', ');
|
|
294
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs});`);
|
|
295
|
+
} else {
|
|
296
|
+
for (const col of table.columns) {
|
|
297
|
+
if (existing.has(col.name)) continue;
|
|
298
|
+
// SQLite refuses PRIMARY KEY / NOT NULL-without-default on ALTER
|
|
299
|
+
// TABLE ADD COLUMN. Strip those rather than fail the migration.
|
|
300
|
+
const addable = {
|
|
301
|
+
...col,
|
|
302
|
+
primary: false,
|
|
303
|
+
autoincrement: false,
|
|
304
|
+
not_null: col.not_null && col.default != null,
|
|
305
|
+
};
|
|
306
|
+
db.exec(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${columnToSql(addable)};`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const index of (table.indexes || [])) {
|
|
310
|
+
const cols = index.columns.map(quoteIdent).join(', ');
|
|
311
|
+
const idxName = `${table.name}_${index.columns.join('_')}_idx`;
|
|
312
|
+
db.exec(
|
|
313
|
+
`CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(idxName)} `
|
|
314
|
+
+ `ON ${quoteIdent(table.name)} (${cols});`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (process.env.DEBUG) {
|
|
319
|
+
console.error(`Failed to apply declared schema for table ${table.name}: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
handle.declaredTables = new Set(tables.map(t => t.name));
|
|
325
|
+
handle.schemaSig = sig;
|
|
326
|
+
}
|
|
327
|
+
|
|
239
328
|
function now() { return new Date().toISOString(); }
|
|
240
329
|
|
|
241
330
|
function deepMerge(base, patch) {
|
|
@@ -261,10 +350,15 @@ function deepMerge(base, patch) {
|
|
|
261
350
|
* @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
|
|
262
351
|
* Called (debounced) after every write commits. The workspace server
|
|
263
352
|
* turns this into an SSE `plugin_state_changed` event for the browser.
|
|
264
|
-
* @
|
|
353
|
+
* @param {object[]} [opts.tables]
|
|
354
|
+
* Tables declared in the plugin manifest's `config.state.tables`. They
|
|
355
|
+
* are created (and additively migrated) when the DB opens, so a plugin
|
|
356
|
+
* can own real domain tables — questions, documents, answers — without
|
|
357
|
+
* shipping its own migration logic.
|
|
358
|
+
* @returns proxy with { get, set, patch, append, list, query, delete, summary, readTable, close, db, path }
|
|
265
359
|
*/
|
|
266
|
-
export function makePluginState(pluginName, { emit = null } = {}) {
|
|
267
|
-
const { db, path: dbPath } = openDb(pluginName);
|
|
360
|
+
export function makePluginState(pluginName, { emit = null, tables = [] } = {}) {
|
|
361
|
+
const { db, path: dbPath, declaredTables } = openDb(pluginName, { tables });
|
|
268
362
|
|
|
269
363
|
// One debounce timer per (kind, target). Fast writes coalesce into
|
|
270
364
|
// exactly one plugin_state_changed event. Pending entry is stored so
|
|
@@ -398,6 +492,64 @@ export function makePluginState(pluginName, { emit = null } = {}) {
|
|
|
398
492
|
return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
|
|
399
493
|
},
|
|
400
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Read the slices a manifest flagged as `context_always` so the agent
|
|
497
|
+
* can orient itself at session start without spending a tool call.
|
|
498
|
+
*
|
|
499
|
+
* Returns `{ kv: { <key>: value }, streams: { <stream>: [rows] } }`.
|
|
500
|
+
* Anything the declaration doesn't name is deliberately omitted —
|
|
501
|
+
* this is the "small and high-signal" tier, and its whole value is
|
|
502
|
+
* that it stays cheap.
|
|
503
|
+
*
|
|
504
|
+
* @param {object[]} decl normalized `config.state.context_always`
|
|
505
|
+
*/
|
|
506
|
+
summary(decl = []) {
|
|
507
|
+
const out = { kv: {}, streams: {} };
|
|
508
|
+
for (const item of (Array.isArray(decl) ? decl : [])) {
|
|
509
|
+
if (!item || typeof item !== 'object') continue;
|
|
510
|
+
try {
|
|
511
|
+
if (item.kind === 'records' && item.stream) {
|
|
512
|
+
out.streams[item.stream] = this.list(item.stream, {
|
|
513
|
+
limit: item.limit || 5,
|
|
514
|
+
order: 'desc',
|
|
515
|
+
});
|
|
516
|
+
} else if (item.kind === 'kv' && item.key) {
|
|
517
|
+
out.kv[item.key] = this.get(item.key, null);
|
|
518
|
+
}
|
|
519
|
+
} catch { /* one unreadable slice must not blank the whole summary */ }
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Read rows from a table the plugin declared in `config.state.tables`.
|
|
526
|
+
* Backs the auto-generated `context_tools`.
|
|
527
|
+
*
|
|
528
|
+
* Only declared tables are reachable, so a manifest typo can't turn a
|
|
529
|
+
* generated tool into arbitrary table access. `where` is plugin-authored
|
|
530
|
+
* SQL (same trust model as `query()`); values are always bound.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} table
|
|
533
|
+
* @param {object} [opts]
|
|
534
|
+
* @param {string} [opts.where] e.g. "topic = ?"
|
|
535
|
+
* @param {any[]} [opts.params] bound positionally against `where`
|
|
536
|
+
* @param {number} [opts.limit]
|
|
537
|
+
* @returns {object[]}
|
|
538
|
+
*/
|
|
539
|
+
readTable(table, { where = '', params = [], limit = 50 } = {}) {
|
|
540
|
+
if (!DatabaseSync) {
|
|
541
|
+
throw new Error('Declared state tables require the native node:sqlite backend');
|
|
542
|
+
}
|
|
543
|
+
const name = String(table || '');
|
|
544
|
+
if (!declaredTables.has(name)) {
|
|
545
|
+
throw new Error(`unknown declared state table: ${name || '(none)'}`);
|
|
546
|
+
}
|
|
547
|
+
const args = Array.isArray(params) ? params : [params];
|
|
548
|
+
const cap = Math.max(1, Math.min(1000, Math.floor(limit) || 50));
|
|
549
|
+
const clause = where ? ` WHERE ${where}` : '';
|
|
550
|
+
return db.prepare(`SELECT * FROM ${quoteIdent(name)}${clause} LIMIT ?`).all(...args, cap);
|
|
551
|
+
},
|
|
552
|
+
|
|
401
553
|
/** Direct DatabaseSync handle for callers that know what they need. */
|
|
402
554
|
get db() { return db; },
|
|
403
555
|
/** Absolute path to the DB file on disk. */
|
package/src/terminal/main.mjs
CHANGED
|
@@ -291,6 +291,12 @@ async function main() {
|
|
|
291
291
|
return;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
if (subcommand === 'mcp') {
|
|
295
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
296
|
+
await handleMcpCommand(subcommandArgs);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
if (subcommand === 'plugin' || subcommand === 'plugins') {
|
|
295
301
|
// `install`/`pull` moved to top-level. Detect the old form and redirect.
|
|
296
302
|
if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
|
|
@@ -338,6 +344,9 @@ async function main() {
|
|
|
338
344
|
bahulam login Sign in via browser
|
|
339
345
|
bahulam logout Sign out and clear credentials
|
|
340
346
|
bahulam init Scaffold .bahulam config, memory, hooks, tasks
|
|
347
|
+
bahulam mcp add <name> ... Register an MCP server
|
|
348
|
+
bahulam mcp list List registered MCP servers
|
|
349
|
+
bahulam mcp test <name> Test an MCP server connection
|
|
341
350
|
bahulam version Show version
|
|
342
351
|
|
|
343
352
|
\x1b[1mDaemon:\x1b[0m
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -4398,11 +4398,51 @@ async function handleCommand(input, ctx) {
|
|
|
4398
4398
|
process.stderr.write(`\n ${c.brand('Goodbye!')}\n\n`);
|
|
4399
4399
|
process.exit(0);
|
|
4400
4400
|
|
|
4401
|
+
case '/mcp': {
|
|
4402
|
+
await handleMcpSlashCommand(rest, ctx);
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4401
4406
|
default:
|
|
4402
4407
|
process.stderr.write(` ${c.gray(`Unknown: ${cmd}. Type /help.`)}\n`);
|
|
4403
4408
|
}
|
|
4404
4409
|
}
|
|
4405
4410
|
|
|
4411
|
+
/**
|
|
4412
|
+
* /mcp slash command — dispatches to the CLI's handleMcpCommand.
|
|
4413
|
+
* Supports: /mcp (status), /mcp add, /mcp remove, /mcp list, /mcp test.
|
|
4414
|
+
*/
|
|
4415
|
+
async function handleMcpSlashCommand(rest, ctx) {
|
|
4416
|
+
const sub = String(rest || '').trim().split(/\s+/)[0]?.toLowerCase();
|
|
4417
|
+
if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
|
|
4418
|
+
// No subcommand → show connected server status from session state
|
|
4419
|
+
const mcpClients = ctx?.toolExecutor?._mcpClients || [];
|
|
4420
|
+
if (mcpClients.length === 0) {
|
|
4421
|
+
process.stderr.write(` ${c.dim('No MCP servers connected. Use:')} ${c.brand('/mcp add <name> --command <cmd> | --url <url>')}\n`);
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
process.stderr.write(`\n ${c.bold('MCP Servers')} (${mcpClients.length}):\n`);
|
|
4425
|
+
for (let i = 0; i < mcpClients.length; i++) {
|
|
4426
|
+
const cl = mcpClients[i];
|
|
4427
|
+
const name = cl.name || cl.config?.command || 'unknown';
|
|
4428
|
+
const endpoint = cl.config?.url || cl.config?.command || 'unknown';
|
|
4429
|
+
const status = cl.connected ? c.green('connected') : c.yellow('disconnected');
|
|
4430
|
+
process.stderr.write(` ${c.brand(String(i + 1).padStart(2))}. ${c.brand(name.padEnd(20))} ${status} ${c.dim(endpoint)}\n`);
|
|
4431
|
+
}
|
|
4432
|
+
process.stderr.write('\n');
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
// Dispatch to the CLI handler — same code path as `bahulam mcp`
|
|
4437
|
+
try {
|
|
4438
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
4439
|
+
const mcpArgs = String(rest || '').trim().split(/\s+/);
|
|
4440
|
+
await handleMcpCommand(mcpArgs);
|
|
4441
|
+
} catch (err) {
|
|
4442
|
+
process.stderr.write(` ${c.red('✗')} ${c.dim(err.message)}\n`);
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4406
4446
|
// ── Fetch User Profile ──
|
|
4407
4447
|
|
|
4408
4448
|
async function fetchUser(ctx) {
|
package/src/tools/registry.mjs
CHANGED
|
@@ -111,7 +111,13 @@ export function createToolRegistry({
|
|
|
111
111
|
if (!pluginName) return null;
|
|
112
112
|
if (pluginStateHandles.has(pluginName)) return pluginStateHandles.get(pluginName);
|
|
113
113
|
const { makePluginState } = await import('../plugins/state.mjs');
|
|
114
|
-
|
|
114
|
+
// Pass the plugin's declared tables through, otherwise this path
|
|
115
|
+
// opens the shared handle without them — and `applyDeclaredSchema`
|
|
116
|
+
// treats an empty list as "this plugin declares nothing", which
|
|
117
|
+
// would *un-apply* a schema the executor path had already applied.
|
|
118
|
+
const plugin = pluginRegistry?.get?.(pluginName) || null;
|
|
119
|
+
const tables = plugin?.config?.state?.tables || [];
|
|
120
|
+
const state = makePluginState(pluginName, { emit: stateEmit, tables });
|
|
115
121
|
pluginStateHandles.set(pluginName, state);
|
|
116
122
|
return state;
|
|
117
123
|
}
|
package/src/ui/commands.mjs
CHANGED
|
@@ -345,15 +345,40 @@ export const COMMANDS = {
|
|
|
345
345
|
},
|
|
346
346
|
|
|
347
347
|
'/mcp': {
|
|
348
|
-
description: '
|
|
349
|
-
handler(args, state) {
|
|
350
|
-
|
|
351
|
-
|
|
348
|
+
description: 'Manage MCP servers (add, remove, list, test, status)',
|
|
349
|
+
async handler(args, state) {
|
|
350
|
+
const sub = (args || '').trim().split(/\s+/)[0]?.toLowerCase();
|
|
351
|
+
|
|
352
|
+
// No subcommand → show status (default)
|
|
353
|
+
if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
|
|
354
|
+
if (!state._mcpClients || state._mcpClients.length === 0) {
|
|
355
|
+
return 'No MCP servers connected. Use: /mcp add <name> --command <cmd> | --url <url>';
|
|
356
|
+
}
|
|
357
|
+
const lines = state._mcpClients.map((c, i) => {
|
|
358
|
+
const name = c.name || c.config?.command || 'unknown';
|
|
359
|
+
const endpoint = c.config?.url || c.config?.command || 'unknown';
|
|
360
|
+
return ` ${i + 1}. ${name} (${endpoint}) — ${c.connected ? 'connected' : 'disconnected'}`;
|
|
361
|
+
});
|
|
362
|
+
return `MCP servers:\n${lines.join('\n')}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Dispatch to the CLI handler — same code path as `bahulam mcp`
|
|
366
|
+
try {
|
|
367
|
+
const { handleMcpCommand } = await import('../commands/mcp.mjs');
|
|
368
|
+
const mcpArgs = (args || '').trim().split(/\s+/);
|
|
369
|
+
// Capture stdout/stderr from the handler
|
|
370
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
371
|
+
const origErrWrite = process.stderr.write.bind(process.stderr);
|
|
372
|
+
let captured = '';
|
|
373
|
+
process.stderr.write = (chunk) => { captured += chunk; return true; };
|
|
374
|
+
process.stdout.write = (chunk) => { captured += chunk; return true; };
|
|
375
|
+
await handleMcpCommand(mcpArgs);
|
|
376
|
+
process.stderr.write = origErrWrite;
|
|
377
|
+
process.stdout.write = origWrite;
|
|
378
|
+
return captured.trim() || 'Done.';
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return `MCP command failed: ${err.message}`;
|
|
352
381
|
}
|
|
353
|
-
const lines = state._mcpClients.map((c, i) =>
|
|
354
|
-
` ${i + 1}. ${c.config?.command || 'unknown'} — ${c.connected ? 'connected' : 'disconnected'}`
|
|
355
|
-
);
|
|
356
|
-
return `MCP servers:\n${lines.join('\n')}`;
|
|
357
382
|
},
|
|
358
383
|
},
|
|
359
384
|
|
|
@@ -516,7 +541,7 @@ export const COMMANDS = {
|
|
|
516
541
|
* @param {object} state - agent loop state
|
|
517
542
|
* @returns {{ response: string, exit: boolean }}
|
|
518
543
|
*/
|
|
519
|
-
export function executeCommand(input, state) {
|
|
544
|
+
export async function executeCommand(input, state) {
|
|
520
545
|
const parts = input.split(/\s+/);
|
|
521
546
|
const cmd = parts[0].toLowerCase();
|
|
522
547
|
const args = parts.slice(1).join(' ');
|
|
@@ -526,7 +551,7 @@ export function executeCommand(input, state) {
|
|
|
526
551
|
return { response: `Unknown command: ${cmd}. Type /help for available commands.`, exit: false };
|
|
527
552
|
}
|
|
528
553
|
|
|
529
|
-
const response = command.handler(args, state);
|
|
554
|
+
const response = await command.handler(args, state);
|
|
530
555
|
return { response, exit: response === 'EXIT' };
|
|
531
556
|
}
|
|
532
557
|
|