@bahulam/code 0.1.9 → 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 +6 -3
- 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 +257 -12
- 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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin state — the Shared Blackboard.
|
|
3
|
+
*
|
|
4
|
+
* Every plugin gets its own SQLite sidecar at ~/.bahulam/data/<name>/state.db.
|
|
5
|
+
* Two built-in tables ship with every DB; plugins are free to `CREATE TABLE`
|
|
6
|
+
* additional ones via `state.query(sql)`:
|
|
7
|
+
*
|
|
8
|
+
* kv — small structured values (watchlists, prefs, cursors, form
|
|
9
|
+
* state). One row per key, JSON-encoded value, last-write-wins
|
|
10
|
+
* with an updated_at timestamp.
|
|
11
|
+
*
|
|
12
|
+
* records — append-only event log (backtest runs, decisions, alerts,
|
|
13
|
+
* anything you want a history of). Named streams via the
|
|
14
|
+
* `stream` column; plugin owns the stream namespace.
|
|
15
|
+
*
|
|
16
|
+
* The exported `makePluginState(pluginName, {emit})` returns a proxy with
|
|
17
|
+
* the methods handlers and view routes call. Every write fires `emit(evt)`
|
|
18
|
+
* synchronously AFTER commit — the SSE bus turns that into a
|
|
19
|
+
* `plugin_state_changed` event so views can re-render live. When the same
|
|
20
|
+
* key/stream is written many times in quick succession the emit hook
|
|
21
|
+
* debounces to at most one event per 50ms per (plugin, kind, target).
|
|
22
|
+
*
|
|
23
|
+
* We use Node's built-in `node:sqlite` (v22+, experimental) so plugin
|
|
24
|
+
* authors get relational storage with zero install steps. The
|
|
25
|
+
* ExperimentalWarning is silenced once at module load.
|
|
26
|
+
*
|
|
27
|
+
* A NOTE ON SAFETY: `state.query(sql, params)` is a raw SQL escape hatch
|
|
28
|
+
* intended for the plugin's OWN tools and views — never expose it to
|
|
29
|
+
* untrusted input (the plugin author owns the SQL). The higher-level
|
|
30
|
+
* get/set/patch/append/list methods parameterize everything.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
37
|
+
|
|
38
|
+
// Silence the single "SQLite is an experimental feature" warning that
|
|
39
|
+
// node:sqlite emits at first import. Users would see it on every plugin
|
|
40
|
+
// launch otherwise, which is noise, not signal.
|
|
41
|
+
{
|
|
42
|
+
const orig = process.emit;
|
|
43
|
+
process.emit = function (name, warning, ...rest) {
|
|
44
|
+
if (name === 'warning' && warning?.name === 'ExperimentalWarning'
|
|
45
|
+
&& /SQLite/i.test(String(warning.message || ''))) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return orig.call(this, name, warning, ...rest);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DATA_ROOT = () => path.join(os.homedir(), '.bahulam', 'data');
|
|
53
|
+
const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
54
|
+
const DEBOUNCE_MS = 50;
|
|
55
|
+
|
|
56
|
+
// Open handles cached per plugin — SQLite in WAL mode is happy with one
|
|
57
|
+
// handle per process, and this is a single-process dev tool. Handles live
|
|
58
|
+
// for the lifetime of the CLI; explicit close() is available for tests.
|
|
59
|
+
const _handles = new Map(); // pluginName -> { db, dir, path }
|
|
60
|
+
|
|
61
|
+
function pluginDataDir(pluginName) {
|
|
62
|
+
if (!PLUGIN_NAME_RE.test(pluginName)) {
|
|
63
|
+
throw new Error(`invalid plugin name for state dir: ${pluginName}`);
|
|
64
|
+
}
|
|
65
|
+
const dir = path.join(DATA_ROOT(), pluginName);
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openDb(pluginName) {
|
|
71
|
+
if (_handles.has(pluginName)) return _handles.get(pluginName);
|
|
72
|
+
const dir = pluginDataDir(pluginName);
|
|
73
|
+
const dbPath = path.join(dir, 'state.db');
|
|
74
|
+
const db = new DatabaseSync(dbPath);
|
|
75
|
+
// WAL: multiple readers, one writer; robust against concurrent view+agent.
|
|
76
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
77
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
78
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
79
|
+
// Bootstrap schema — idempotent so evolving plugins never crash on start.
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
value TEXT NOT NULL,
|
|
84
|
+
updated_at TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
stream TEXT NOT NULL,
|
|
89
|
+
payload TEXT NOT NULL,
|
|
90
|
+
created_at TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS records_stream_idx
|
|
93
|
+
ON records(stream, id DESC);
|
|
94
|
+
`);
|
|
95
|
+
const handle = { db, dir, path: dbPath };
|
|
96
|
+
_handles.set(pluginName, handle);
|
|
97
|
+
return handle;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function now() { return new Date().toISOString(); }
|
|
101
|
+
|
|
102
|
+
function deepMerge(base, patch) {
|
|
103
|
+
if (Array.isArray(base) || Array.isArray(patch)) return patch;
|
|
104
|
+
if (base == null || typeof base !== 'object') return patch;
|
|
105
|
+
if (patch == null || typeof patch !== 'object') return patch;
|
|
106
|
+
const out = { ...base };
|
|
107
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
108
|
+
if (v && typeof v === 'object' && !Array.isArray(v)
|
|
109
|
+
&& out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
|
|
110
|
+
out[k] = deepMerge(out[k], v);
|
|
111
|
+
} else {
|
|
112
|
+
out[k] = v;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a per-plugin state proxy.
|
|
120
|
+
* @param {string} pluginName Must match /^[a-z0-9][a-z0-9._-]{0,63}$/i
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
|
|
123
|
+
* Called (debounced) after every write commits. The workspace server
|
|
124
|
+
* turns this into an SSE `plugin_state_changed` event for the browser.
|
|
125
|
+
* @returns proxy with { get, set, patch, append, list, query, delete, close, db, path }
|
|
126
|
+
*/
|
|
127
|
+
export function makePluginState(pluginName, { emit = null } = {}) {
|
|
128
|
+
const { db, path: dbPath } = openDb(pluginName);
|
|
129
|
+
|
|
130
|
+
// One debounce timer per (kind, target). Fast writes coalesce into
|
|
131
|
+
// exactly one plugin_state_changed event. Pending entry is stored so
|
|
132
|
+
// close() can flush synchronously for tests and controlled shutdown.
|
|
133
|
+
const timers = new Map(); // key -> timeout handle
|
|
134
|
+
const pending = new Map(); // key -> event payload to fire on flush
|
|
135
|
+
function fire(op, kind, target) {
|
|
136
|
+
if (typeof emit !== 'function') return;
|
|
137
|
+
const key = `${kind}:${target}`;
|
|
138
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
139
|
+
pending.set(key, { plugin: pluginName, op, kind, target, at: now() });
|
|
140
|
+
timers.set(key, setTimeout(() => {
|
|
141
|
+
const evt = pending.get(key);
|
|
142
|
+
timers.delete(key);
|
|
143
|
+
pending.delete(key);
|
|
144
|
+
if (evt) {
|
|
145
|
+
try { emit(evt); }
|
|
146
|
+
catch { /* emit failure must never surface into the tool call */ }
|
|
147
|
+
}
|
|
148
|
+
}, DEBOUNCE_MS));
|
|
149
|
+
}
|
|
150
|
+
function flushPending() {
|
|
151
|
+
for (const [key, evt] of pending) {
|
|
152
|
+
clearTimeout(timers.get(key));
|
|
153
|
+
try { emit(evt); } catch { /* swallow */ }
|
|
154
|
+
}
|
|
155
|
+
timers.clear();
|
|
156
|
+
pending.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Prepared statements are cached on first use (better-sqlite3-style perf,
|
|
160
|
+
// node:sqlite exposes prepare() too).
|
|
161
|
+
const stmts = {
|
|
162
|
+
get: db.prepare('SELECT value FROM kv WHERE key = ?'),
|
|
163
|
+
upsert: db.prepare('INSERT INTO kv(key, value, updated_at) VALUES(?, ?, ?) '
|
|
164
|
+
+ 'ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at'),
|
|
165
|
+
del: db.prepare('DELETE FROM kv WHERE key = ?'),
|
|
166
|
+
keys: db.prepare('SELECT key FROM kv ORDER BY key'),
|
|
167
|
+
append: db.prepare('INSERT INTO records(stream, payload, created_at) VALUES(?, ?, ?)'),
|
|
168
|
+
listAsc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id ASC LIMIT ?'),
|
|
169
|
+
listDesc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id DESC LIMIT ?'),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
/** Read one key. Returns `fallback` (default null) when the key is absent. */
|
|
174
|
+
get(key, fallback = null) {
|
|
175
|
+
const row = stmts.get.get(String(key));
|
|
176
|
+
if (!row) return fallback;
|
|
177
|
+
try { return JSON.parse(row.value); }
|
|
178
|
+
catch { return fallback; }
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/** Write one key with a whole value. Fires plugin_state_changed. */
|
|
182
|
+
set(key, value) {
|
|
183
|
+
const k = String(key);
|
|
184
|
+
stmts.upsert.run(k, JSON.stringify(value), now());
|
|
185
|
+
fire('set', 'kv', k);
|
|
186
|
+
return value;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Deep-merge a partial into an existing object under `key`. Arrays
|
|
191
|
+
* replace; nested objects merge recursively. When the key is absent,
|
|
192
|
+
* `patch` is stored as-is. Matches the "PATCH endpoint for surgeon
|
|
193
|
+
* precision" contract.
|
|
194
|
+
*/
|
|
195
|
+
patch(key, partial) {
|
|
196
|
+
const k = String(key);
|
|
197
|
+
const cur = this.get(k, null);
|
|
198
|
+
const next = deepMerge(cur, partial);
|
|
199
|
+
stmts.upsert.run(k, JSON.stringify(next), now());
|
|
200
|
+
fire('patch', 'kv', k);
|
|
201
|
+
return next;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/** Remove one key. */
|
|
205
|
+
delete(key) {
|
|
206
|
+
const k = String(key);
|
|
207
|
+
const info = stmts.del.run(k);
|
|
208
|
+
if (info.changes > 0) fire('delete', 'kv', k);
|
|
209
|
+
return info.changes > 0;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/** List every kv key currently present. */
|
|
213
|
+
keys() { return stmts.keys.all().map(r => r.key); },
|
|
214
|
+
|
|
215
|
+
/** Append one row to a named stream. Returns the new row's id. */
|
|
216
|
+
append(stream, payload) {
|
|
217
|
+
const s = String(stream);
|
|
218
|
+
const info = stmts.append.run(s, JSON.stringify(payload), now());
|
|
219
|
+
fire('append', 'records', s);
|
|
220
|
+
return Number(info.lastInsertRowid);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read from a stream.
|
|
225
|
+
* @param {string} stream
|
|
226
|
+
* @param {object} [opts] { limit?: number = 50, order?: 'asc'|'desc' = 'desc' }
|
|
227
|
+
* @returns [{ id, payload, created_at }]
|
|
228
|
+
*/
|
|
229
|
+
list(stream, { limit = 50, order = 'desc' } = {}) {
|
|
230
|
+
const s = String(stream);
|
|
231
|
+
const cap = Math.max(1, Math.min(10000, Math.floor(limit)));
|
|
232
|
+
const stmt = order === 'asc' ? stmts.listAsc : stmts.listDesc;
|
|
233
|
+
return stmt.all(s, cap).map(r => ({
|
|
234
|
+
id: r.id,
|
|
235
|
+
payload: safeParse(r.payload),
|
|
236
|
+
created_at: r.created_at,
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Raw SQL escape hatch — for the plugin's own advanced use. Always
|
|
242
|
+
* use parameters, never string-concat user input into SQL. Returns
|
|
243
|
+
* whatever the underlying prepared statement returns; SELECTs come
|
|
244
|
+
* back as an array of rows.
|
|
245
|
+
*/
|
|
246
|
+
query(sql, params = []) {
|
|
247
|
+
const stmt = db.prepare(String(sql));
|
|
248
|
+
const args = Array.isArray(params) ? params : [params];
|
|
249
|
+
// node:sqlite prepared statements expose all() for SELECT-like
|
|
250
|
+
// queries and run() for DML; iterate() is available too.
|
|
251
|
+
const first = String(sql).trim().slice(0, 6).toUpperCase();
|
|
252
|
+
if (first.startsWith('SELECT') || first.startsWith('PRAGMA')) {
|
|
253
|
+
return stmt.all(...args);
|
|
254
|
+
}
|
|
255
|
+
const info = stmt.run(...args);
|
|
256
|
+
// Any DML on kv/records is announced generically so views can
|
|
257
|
+
// refresh; more specific writes go through set/patch/append.
|
|
258
|
+
fire('query', 'kv', '*');
|
|
259
|
+
return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
/** Direct DatabaseSync handle for callers that know what they need. */
|
|
263
|
+
get db() { return db; },
|
|
264
|
+
/** Absolute path to the DB file on disk. */
|
|
265
|
+
get path() { return dbPath; },
|
|
266
|
+
|
|
267
|
+
/** Test hook — closes the underlying handle. Flushes any pending
|
|
268
|
+
* emit events synchronously so tests can observe them without a wait. */
|
|
269
|
+
close() {
|
|
270
|
+
flushPending();
|
|
271
|
+
db.close();
|
|
272
|
+
_handles.delete(pluginName);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function safeParse(text) {
|
|
278
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Test helper — reset all cached handles. Called by tests between cases so
|
|
283
|
+
* a temp $HOME override actually takes effect.
|
|
284
|
+
*/
|
|
285
|
+
export function _resetForTests() {
|
|
286
|
+
for (const h of _handles.values()) {
|
|
287
|
+
try { h.db.close(); } catch { /* ignore */ }
|
|
288
|
+
}
|
|
289
|
+
_handles.clear();
|
|
290
|
+
}
|
package/src/terminal/agents.mjs
CHANGED
|
@@ -163,6 +163,22 @@ export function findBuiltinAgent(agentName) {
|
|
|
163
163
|
return BUILTIN_AGENTS.find(agent => agent.command === target || agent.name.toLowerCase() === target) || null;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Search for an agent across built-in agents, project .bahulam/agents, and plugin agents.
|
|
168
|
+
* @param {string} agentName
|
|
169
|
+
* @param {object} [agentLoader] - Instance of AgentLoader with loadFromPlugins() called
|
|
170
|
+
* @returns {object|null}
|
|
171
|
+
*/
|
|
172
|
+
export function findAgent(agentName, agentLoader = null) {
|
|
173
|
+
const builtin = findBuiltinAgent(agentName);
|
|
174
|
+
if (builtin) return builtin;
|
|
175
|
+
if (agentLoader) {
|
|
176
|
+
const local = agentLoader.get(agentName);
|
|
177
|
+
if (local) return local;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
166
182
|
export function localAgentMatches(agent, target) {
|
|
167
183
|
const needle = String(target || '').trim().toLowerCase();
|
|
168
184
|
if (!needle) return false;
|
|
@@ -344,7 +360,8 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
|
|
|
344
360
|
* @param {Function} renderEvent - Event renderer function
|
|
345
361
|
*/
|
|
346
362
|
export async function runAgent(agentName, instruction, ctx, session, renderEvent) {
|
|
347
|
-
const
|
|
363
|
+
const agentLoader = ctx?.agentLoader || null;
|
|
364
|
+
const agent = findAgent(agentName, agentLoader);
|
|
348
365
|
if (!agent) {
|
|
349
366
|
process.stderr.write(` ${c.red('Unknown agent: ' + agentName)}\n`);
|
|
350
367
|
return;
|
package/src/terminal/init.mjs
CHANGED
|
@@ -28,7 +28,7 @@ Format v1. Markdown files are intentionally hand-editable.
|
|
|
28
28
|
'config.json': JSON.stringify({
|
|
29
29
|
version: 1,
|
|
30
30
|
context: {
|
|
31
|
-
loadEveryTurn: ['
|
|
31
|
+
loadEveryTurn: ['BAHULAM.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
|
|
32
32
|
showReloadNotice: true,
|
|
33
33
|
},
|
|
34
34
|
planning: { owner: 'auto', onUserEditedPlan: 'prefer_user_plan' },
|
|
@@ -57,7 +57,7 @@ Format v1. Markdown files are intentionally hand-editable.
|
|
|
57
57
|
Stop: [],
|
|
58
58
|
},
|
|
59
59
|
}, null, 2) + '\n',
|
|
60
|
-
'
|
|
60
|
+
'BAHULAM.md': `# Project
|
|
61
61
|
|
|
62
62
|
## Quick Facts
|
|
63
63
|
- Stack:
|
package/src/terminal/main.mjs
CHANGED
|
@@ -23,6 +23,64 @@ import { BahulamAuth as Auth } from '../auth/bahulam-auth.mjs';
|
|
|
23
23
|
const subcommand = process.argv[2];
|
|
24
24
|
const subcommandArgs = process.argv.slice(3);
|
|
25
25
|
|
|
26
|
+
const PLUGIN_MANAGEMENT_COMMANDS = new Set([
|
|
27
|
+
'install', 'validate', 'check', 'lint',
|
|
28
|
+
'list', 'ls', 'remove', 'rm', 'uninstall',
|
|
29
|
+
'enable', 'disable', 'info', 'update', 'upgrade',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function parsePluginArgs(argv) {
|
|
33
|
+
const parsed = {
|
|
34
|
+
action: null, // 'open' (default) or a management verb
|
|
35
|
+
pluginName: null,
|
|
36
|
+
targetPath: null,
|
|
37
|
+
source: null, // install source: git url, tarball, local dir
|
|
38
|
+
port: 0,
|
|
39
|
+
open: true,
|
|
40
|
+
help: false,
|
|
41
|
+
json: false,
|
|
42
|
+
global: true, // install target: ~/.bahulam vs project .bahulam
|
|
43
|
+
force: false,
|
|
44
|
+
ref: null, // git branch/tag/commit
|
|
45
|
+
};
|
|
46
|
+
const positional = [];
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
switch (arg) {
|
|
50
|
+
case '--help':
|
|
51
|
+
case '-h': parsed.help = true; break;
|
|
52
|
+
case '--port': parsed.port = Number(argv[++i]) || 0; break;
|
|
53
|
+
case '--no-open': parsed.open = false; break;
|
|
54
|
+
case '--json': parsed.json = true; parsed.open = false; break;
|
|
55
|
+
case '--project': parsed.global = false; break;
|
|
56
|
+
case '--global': parsed.global = true; break;
|
|
57
|
+
case '--force': case '-f': parsed.force = true; break;
|
|
58
|
+
case '--ref': case '--tag': case '--branch': parsed.ref = argv[++i]; break;
|
|
59
|
+
default:
|
|
60
|
+
if (!arg.startsWith('-')) positional.push(arg);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (positional.length && PLUGIN_MANAGEMENT_COMMANDS.has(positional[0].toLowerCase())) {
|
|
65
|
+
parsed.action = positional.shift().toLowerCase();
|
|
66
|
+
if (parsed.action === 'install') parsed.source = positional.shift() || null;
|
|
67
|
+
else if (['validate', 'check', 'lint'].includes(parsed.action)) {
|
|
68
|
+
// Accepts either a directory path or an installed plugin name.
|
|
69
|
+
const arg = positional.shift() || null;
|
|
70
|
+
if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
|
|
71
|
+
else parsed.pluginName = arg;
|
|
72
|
+
}
|
|
73
|
+
else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade'].includes(parsed.action)) {
|
|
74
|
+
parsed.pluginName = positional.shift() || null;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
parsed.action = 'open';
|
|
78
|
+
parsed.pluginName = positional.shift() || null;
|
|
79
|
+
parsed.targetPath = positional.shift() || null;
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
26
84
|
function parseKeplerSubcommandArgs(command, argv) {
|
|
27
85
|
const parsed = {
|
|
28
86
|
command,
|
|
@@ -221,6 +279,18 @@ async function main() {
|
|
|
221
279
|
return;
|
|
222
280
|
}
|
|
223
281
|
|
|
282
|
+
if (subcommand === 'plugin' || subcommand === 'plugins') {
|
|
283
|
+
const args = parsePluginArgs(subcommandArgs);
|
|
284
|
+
if (args.action && args.action !== 'open') {
|
|
285
|
+
const { handlePluginManagementCommand } = await import('../commands/plugin-manage.mjs');
|
|
286
|
+
await handlePluginManagementCommand(args, { cwd: process.cwd() });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const { handlePluginCommand } = await import('../commands/plugin.mjs');
|
|
290
|
+
await handlePluginCommand(args, { cwd: process.cwd() });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
224
294
|
if (subcommand === 'version' || subcommand === '--version' || subcommand === '-v') {
|
|
225
295
|
const { createRequire } = await import('node:module');
|
|
226
296
|
const require = createRequire(import.meta.url);
|
|
@@ -260,6 +330,9 @@ async function main() {
|
|
|
260
330
|
bahulam workspace list List recent local workspace sessions
|
|
261
331
|
bahulam local open [path] Alias for workspace open
|
|
262
332
|
|
|
333
|
+
\x1b[1mPlugins:\x1b[0m
|
|
334
|
+
bahulam plugin <name> [path] Open a workspace with a named plugin
|
|
335
|
+
|
|
263
336
|
\x1b[1mAnalytics:\x1b[0m
|
|
264
337
|
bahulam sessions List recent local sessions
|
|
265
338
|
bahulam stats Show aggregate local session stats
|
|
@@ -302,8 +375,8 @@ async function main() {
|
|
|
302
375
|
ANTHROPIC_API_KEY Direct Anthropic API key
|
|
303
376
|
OPENROUTER_API_KEY OpenRouter API key
|
|
304
377
|
BAHULAM_CONFIG_DIR Override config directory (default: ~/.bahulam)
|
|
305
|
-
|
|
306
|
-
|
|
378
|
+
BAHULAM_CONFIG_DIR Legacy config directory override
|
|
379
|
+
BAHULAM_RECONNECT_MAX_ELAPSED_MS
|
|
307
380
|
Max reconnect window for dropped streams
|
|
308
381
|
BAHULAM_TTY_MODE=stable Scrollback-safe transcript if fixed dock redraws leak
|
|
309
382
|
BAHULAM_BLOCK_SEPARATOR Tool/content separator: space, dotted, or off
|
|
@@ -211,12 +211,12 @@ function exploreRunTotal() {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
function exploreSnapshotEvery() {
|
|
214
|
-
const n = Number.parseInt(process.env.
|
|
214
|
+
const n = Number.parseInt(process.env.BAHULAM_EXPLORE_SNAPSHOT_EVERY || '8', 10);
|
|
215
215
|
return Number.isFinite(n) ? Math.max(1, n) : 8;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
function exploreSnapshotMs() {
|
|
219
|
-
const n = Number.parseInt(process.env.
|
|
219
|
+
const n = Number.parseInt(process.env.BAHULAM_EXPLORE_SNAPSHOT_MS || '900', 10);
|
|
220
220
|
return Number.isFinite(n) ? Math.max(100, n) : 900;
|
|
221
221
|
}
|
|
222
222
|
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import { SkillInstaller } from '../skills/installer.mjs';
|
|
|
64
64
|
import { SkillsLoader } from '../skills/loader.mjs';
|
|
65
65
|
import { openSkillsPicker, formatSkillsList } from './skills-picker.mjs';
|
|
66
66
|
import { createAgentFile, isVsCodeTerminal, listLocalAgents, openAgentFile, syncAgentsToBackend } from '../agents/scaffold.mjs';
|
|
67
|
+
import { PluginRegistry } from '../plugins/registry.mjs';
|
|
67
68
|
import { SessionManager } from '../core/session-manager.mjs';
|
|
68
69
|
import { parseArgs } from '../config/cli-args.mjs';
|
|
69
70
|
import { pickModelOverridesForm } from './repl-model-form.mjs';
|
|
@@ -875,7 +876,7 @@ function handleAttachmentsCommand(rest = '', ctx) {
|
|
|
875
876
|
}
|
|
876
877
|
|
|
877
878
|
async function confirmVisionUpload(ctx, attachments, { skip = false } = {}) {
|
|
878
|
-
if (skip || process.env.
|
|
879
|
+
if (skip || process.env.BAHULAM_VISION_CONFIRM === '0' || process.env.BAHULAM_VISION_CONFIRM === 'false') {
|
|
879
880
|
return true;
|
|
880
881
|
}
|
|
881
882
|
if (!ctx?._rl || !process.stdin.isTTY) return false;
|
|
@@ -1044,6 +1045,43 @@ function printSkillsUsage() {
|
|
|
1044
1045
|
process.stderr.write(` ${c.dim(' /skills update <name> [--project]')}\n`);
|
|
1045
1046
|
}
|
|
1046
1047
|
|
|
1048
|
+
async function handlePluginsCommand(rest = '', ctx) {
|
|
1049
|
+
const argv = String(rest || '').trim().split(/\s+/).filter(Boolean);
|
|
1050
|
+
const action = (argv[0] || 'list').toLowerCase();
|
|
1051
|
+
const known = new Set(['install', 'validate', 'check', 'lint', 'list', 'ls', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'info', 'update', 'upgrade']);
|
|
1052
|
+
|
|
1053
|
+
// Bare `/plugins <name>` (no action verb) is treated as info, mirroring how
|
|
1054
|
+
// `/skills <name>` behaves. `/plugins` alone lists.
|
|
1055
|
+
if (!known.has(action)) {
|
|
1056
|
+
if (argv.length === 0) return handlePluginsCommand('list', ctx);
|
|
1057
|
+
return handlePluginsCommand(`info ${argv.join(' ')}`, ctx);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const args = {
|
|
1061
|
+
action, pluginName: null, source: null,
|
|
1062
|
+
global: true, force: false, ref: null, json: false,
|
|
1063
|
+
};
|
|
1064
|
+
for (let i = 1; i < argv.length; i++) {
|
|
1065
|
+
const a = argv[i];
|
|
1066
|
+
if (a === '--project') args.global = false;
|
|
1067
|
+
else if (a === '--global') args.global = true;
|
|
1068
|
+
else if (a === '--force' || a === '-f') args.force = true;
|
|
1069
|
+
else if (a === '--json') args.json = true;
|
|
1070
|
+
else if (a === '--ref' || a === '--tag' || a === '--branch') args.ref = argv[++i];
|
|
1071
|
+
else if (!a.startsWith('-')) {
|
|
1072
|
+
if (action === 'install' && !args.source) args.source = a;
|
|
1073
|
+
else if (['validate', 'check', 'lint'].includes(action) && !args.source && !args.pluginName) {
|
|
1074
|
+
if (a.includes('/')) args.source = a; else args.pluginName = a;
|
|
1075
|
+
}
|
|
1076
|
+
else if (!args.pluginName) args.pluginName = a;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const { handlePluginManagementCommand } = await import('../commands/plugin-manage.mjs');
|
|
1081
|
+
await handlePluginManagementCommand(args, { cwd: process.cwd(), throwOnError: true });
|
|
1082
|
+
} catch { /* already printed by the handler */ }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1047
1085
|
async function handleSkillsCommand(rest = '', ctx) {
|
|
1048
1086
|
const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
|
|
1049
1087
|
const hasFlag = (flag) => parts.includes(flag);
|
|
@@ -3720,6 +3758,11 @@ async function handleCommand(input, ctx) {
|
|
|
3720
3758
|
await handleSkillsCommand(rest, ctx);
|
|
3721
3759
|
return;
|
|
3722
3760
|
|
|
3761
|
+
case '/plugin':
|
|
3762
|
+
case '/plugins':
|
|
3763
|
+
await handlePluginsCommand(rest, ctx);
|
|
3764
|
+
return;
|
|
3765
|
+
|
|
3723
3766
|
case '/explore':
|
|
3724
3767
|
case '/review':
|
|
3725
3768
|
case '/architect': {
|
|
@@ -3813,6 +3856,7 @@ export async function startTerminalRepl() {
|
|
|
3813
3856
|
return createToolExecutor({
|
|
3814
3857
|
checkpoints,
|
|
3815
3858
|
hookRunner,
|
|
3859
|
+
pluginRegistry,
|
|
3816
3860
|
interactionHandler: askUserInteraction,
|
|
3817
3861
|
onAutoRegisterStart: shouldShowIndexStatus ? (root) => {
|
|
3818
3862
|
const name = path.basename(root || safeCwd()) || root || 'project';
|
|
@@ -3828,6 +3872,7 @@ export async function startTerminalRepl() {
|
|
|
3828
3872
|
});
|
|
3829
3873
|
}
|
|
3830
3874
|
|
|
3875
|
+
const pluginRegistry = new PluginRegistry().scan();
|
|
3831
3876
|
let toolExecutor = null;
|
|
3832
3877
|
const skipPerms = cliArgs.skipPermissions;
|
|
3833
3878
|
let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
@@ -4224,8 +4269,8 @@ export async function startTerminalRepl() {
|
|
|
4224
4269
|
printBanner(auth);
|
|
4225
4270
|
|
|
4226
4271
|
// Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
|
|
4227
|
-
//
|
|
4228
|
-
if (process.env.
|
|
4272
|
+
// BAHULAM_NO_PREFLIGHT=1 (used by tests / scripted runs).
|
|
4273
|
+
if (process.env.BAHULAM_NO_PREFLIGHT !== '1' && !cliArgs.skipPermissions) {
|
|
4229
4274
|
try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
|
|
4230
4275
|
catch { /* preflight is best-effort */ }
|
|
4231
4276
|
}
|
|
@@ -4902,6 +4947,7 @@ export async function startTerminalRepl() {
|
|
|
4902
4947
|
token: creds.token,
|
|
4903
4948
|
toolExecutor,
|
|
4904
4949
|
approvalManager: approval,
|
|
4950
|
+
pluginRegistry,
|
|
4905
4951
|
});
|
|
4906
4952
|
}
|
|
4907
4953
|
const client = streamClient;
|
|
@@ -92,7 +92,7 @@ export const AnalyzeImageTool = {
|
|
|
92
92
|
if (!creds.backendUrl || !creds.token) {
|
|
93
93
|
return {
|
|
94
94
|
success: false,
|
|
95
|
-
output: 'analyze_image requires CLI auth. Run `bahulam-code login` or set B0_TOKEN
|
|
95
|
+
output: 'analyze_image requires CLI auth. Run `bahulam-code login` or set B0_TOKEN.',
|
|
96
96
|
_tool: 'analyze_image',
|
|
97
97
|
};
|
|
98
98
|
}
|
|
@@ -73,7 +73,7 @@ const PROJECT_MARKERS = [
|
|
|
73
73
|
'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', // Java/Kotlin
|
|
74
74
|
'Makefile', 'CMakeLists.txt', // C/C++
|
|
75
75
|
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
|
76
|
-
'AGENTS.md', '
|
|
76
|
+
'AGENTS.md', 'BAHULAM.md', 'CLAUDE.md', // Agent config lives at root
|
|
77
77
|
'.editorconfig', // Broad but a strong "this is a repo" signal
|
|
78
78
|
];
|
|
79
79
|
|
|
@@ -350,7 +350,7 @@ function defaultScratchRoots() {
|
|
|
350
350
|
'/private/tmp',
|
|
351
351
|
os.tmpdir(),
|
|
352
352
|
process.env.TMPDIR,
|
|
353
|
-
...(process.env.
|
|
353
|
+
...(process.env.BAHULAM_SCRATCH_ROOTS || '')
|
|
354
354
|
.split(path.delimiter)
|
|
355
355
|
.map(s => s.trim())
|
|
356
356
|
.filter(Boolean),
|
|
@@ -399,13 +399,13 @@ export class ProjectRegistry {
|
|
|
399
399
|
|
|
400
400
|
// PRD-69 project context is live metadata, not index cache. Re-read it on
|
|
401
401
|
// every registration attempt so repeated get_project_overview calls pick up
|
|
402
|
-
// .bahulam/
|
|
402
|
+
// .bahulam/BAHULAM.md, goal/plan/style, skills, AGENTS.md, etc. changes.
|
|
403
403
|
_attachLiveContext(resource, root) {
|
|
404
|
-
// Resolver — prefers .bahulam/, falls back to .kepler/ for legacy projects.
|
|
405
404
|
const bahulamDir = projectConfigDir(root);
|
|
406
405
|
resource.environment = detectEnvironment();
|
|
407
|
-
resource.project_context = _readIfExists(
|
|
408
|
-
_readIfExists(
|
|
406
|
+
resource.project_context = _readIfExists(root, 'AGENTS.md', 10000) ||
|
|
407
|
+
_readIfExists(bahulamDir, 'BAHULAM.md', 10000) ||
|
|
408
|
+
_readIfExists(root, 'BAHULAM.md', 10000) ||
|
|
409
409
|
_readIfExists(bahulamDir, 'project.md', 8000);
|
|
410
410
|
resource.style = _readIfExists(bahulamDir, 'style.md', 4000);
|
|
411
411
|
resource.goal = _readIfExists(bahulamDir, 'goal.md', 2000);
|
|
@@ -413,7 +413,7 @@ export class ProjectRegistry {
|
|
|
413
413
|
resource.skills_index = _scanSkills(bahulamDir);
|
|
414
414
|
|
|
415
415
|
if (!resource.project_context) {
|
|
416
|
-
for (const name of ['.bahulam.md', 'AGENTS.md', 'CLAUDE.md']) {
|
|
416
|
+
for (const name of ['.bahulam.md', 'BAHULAM.md', 'AGENTS.md', 'CLAUDE.md']) {
|
|
417
417
|
const content = _readIfExists(root, name, 8000);
|
|
418
418
|
if (content) { resource.project_context = content; break; }
|
|
419
419
|
}
|
|
@@ -47,6 +47,8 @@ export const COMMANDS = {
|
|
|
47
47
|
'/agents': 'List available agents',
|
|
48
48
|
'/subagents': 'Alias of /agents (list/create/edit/sync sub-agents)',
|
|
49
49
|
'/skills': 'List / install / view / remove skills (SKILL.md bundles)',
|
|
50
|
+
'/plugins': 'List / install / info / update / remove plugins (tools + agents + views)',
|
|
51
|
+
'/plugin': 'Alias of /plugins',
|
|
50
52
|
'/run': 'Run a sub-agent or synced workflow',
|
|
51
53
|
'/explore': 'Code explorer agent',
|
|
52
54
|
'/review': 'Code review agent',
|
|
@@ -162,6 +164,22 @@ export const HELP_GROUPS = [
|
|
|
162
164
|
['/skills update <name>', 'Reinstall from the recorded source'],
|
|
163
165
|
],
|
|
164
166
|
},
|
|
167
|
+
{
|
|
168
|
+
key: 'plugins',
|
|
169
|
+
title: 'Plugins',
|
|
170
|
+
summary: 'community-authored tools, agents, and workspace views',
|
|
171
|
+
commands: [
|
|
172
|
+
['/plugins', 'List installed plugins'],
|
|
173
|
+
['/plugins install <git-url|path|name>', 'Install from git URL, local path, or awesome-bahulam-plugins name'],
|
|
174
|
+
['/plugins install <src> --project', 'Install into .bahulam/plugins (project scope)'],
|
|
175
|
+
['/plugins install <src> --ref <tag>', 'Pin a git ref'],
|
|
176
|
+
['/plugins validate <path|name>', 'Preflight a plugin without installing (schema + handlers + collisions)'],
|
|
177
|
+
['/plugins info <name>', 'Show manifest details + install origin'],
|
|
178
|
+
['/plugins enable|disable <name>', 'Toggle without deleting'],
|
|
179
|
+
['/plugins update <name>', 'Pull the latest for git-installed plugins'],
|
|
180
|
+
['/plugins remove <name>', 'Uninstall a plugin'],
|
|
181
|
+
],
|
|
182
|
+
},
|
|
165
183
|
{
|
|
166
184
|
key: 'workflows',
|
|
167
185
|
title: 'Workflows',
|