@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.
@@ -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
+ }
@@ -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 agent = findBuiltinAgent(agentName);
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;
@@ -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: ['KEPLER.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
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
- 'KEPLER.md': `# Project
60
+ 'BAHULAM.md': `# Project
61
61
 
62
62
  ## Quick Facts
63
63
  - Stack:
@@ -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
- KEPLER_CONFIG_DIR Legacy config directory override
306
- KEPLER_RECONNECT_MAX_ELAPSED_MS
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
@@ -25,7 +25,7 @@ const EXPLORE_TOOL_CATEGORY = new Map([
25
25
  ]);
26
26
 
27
27
  export function exploreCollapseEnabled() {
28
- return process.env.KEPLER_EXPLORE_COLLAPSE !== '0';
28
+ return process.env.BAHULAM_EXPLORE_COLLAPSE !== '0';
29
29
  }
30
30
 
31
31
  export function isExploreTool(tool) {
@@ -211,12 +211,12 @@ function exploreRunTotal() {
211
211
  }
212
212
 
213
213
  function exploreSnapshotEvery() {
214
- const n = Number.parseInt(process.env.KEPLER_EXPLORE_SNAPSHOT_EVERY || '8', 10);
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.KEPLER_EXPLORE_SNAPSHOT_MS || '900', 10);
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
 
@@ -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.KEPLER_VISION_CONFIRM === '0' || process.env.KEPLER_VISION_CONFIRM === 'false') {
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
- // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
4228
- if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.skipPermissions) {
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/KEPLER_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', 'CLAUDE.md', 'KEPLER.md', // Agent config lives at root
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.KEPLER_SCRATCH_ROOTS || '')
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/KEPLER.md, goal/plan/style, skills, AGENTS.md, etc. changes.
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(bahulamDir, 'KEPLER.md', 10000) ||
408
- _readIfExists(root, 'KEPLER.md', 10000) ||
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',