@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,6 +93,7 @@ export class AgentLoader {
93
93
  slug,
94
94
  normalizeKey(agent.name),
95
95
  normalizeKey(agent.id),
96
+ ...(Array.isArray(agent.aliases) ? agent.aliases.map(normalizeKey) : []),
96
97
  ].filter(Boolean);
97
98
 
98
99
  // Earlier search paths have higher precedence: project beats global,
@@ -189,6 +189,7 @@ function normalizeAgent(data) {
189
189
  model: data.model || agent.model || null,
190
190
  models: data.models && typeof data.models === 'object' ? data.models : {},
191
191
  tools: Array.isArray(data.tools) ? data.tools : Array.isArray(config?.tools) ? config.tools : [],
192
+ aliases: Array.isArray(data.aliases) ? data.aliases : Array.isArray(metadata.aliases) ? metadata.aliases : [],
192
193
  capabilities: Array.isArray(data.capabilities) ? data.capabilities : Array.isArray(metadata.capabilities) ? metadata.capabilities : [],
193
194
  domains: Array.isArray(data.domains) ? data.domains : Array.isArray(metadata.domains) ? metadata.domains : [],
194
195
  hooks: data.hooks || {},
@@ -30,6 +30,7 @@ export function compactAgentMetadata(agent) {
30
30
  tools: Array.isArray(agent.tools) ? agent.tools : [],
31
31
  capabilities: Array.isArray(agent.capabilities) ? agent.capabilities : [],
32
32
  domains: Array.isArray(agent.domains) ? agent.domains : [],
33
+ aliases: Array.isArray(agent.aliases) ? agent.aliases : [],
33
34
  source_scope: agent.source_scope || 'unknown',
34
35
  source: agent.source || '',
35
36
  content_hash: agent.content_hash || '',
@@ -81,6 +82,7 @@ export function createAgentRegistry({
81
82
  tools: normalizeAgentTools(agentDef.tools || agentDef.agent_tools),
82
83
  capabilities: Array.isArray(agentDef.capabilities) ? agentDef.capabilities : [],
83
84
  domains: Array.isArray(agentDef.domains) ? agentDef.domains : [],
85
+ aliases: Array.isArray(agentDef.aliases) ? agentDef.aliases.map(String).filter(Boolean) : [],
84
86
  prompt: agentDef.prompt || agentDef.system_prompt || agentDef.systemPrompt || '',
85
87
  system_prompt: agentDef.system_prompt || agentDef.systemPrompt || agentDef.prompt || '',
86
88
  source_scope: 'plugin',
@@ -160,7 +162,7 @@ export function createAgentRegistry({
160
162
  const allowlist = new Set(pluginAgentAllowlist());
161
163
  for (const agent of listPluginAgents()) {
162
164
  if (!agent.slug || bySlug.has(agent.slug)) continue;
163
- if (channel === 'workspace' || allowlist.has(agent.slug)) {
165
+ if (channel === 'workspace' || allowlist.has(agent.slug) || agent.entry_agent === true) {
164
166
  bySlug.set(agent.slug, { ...agent, runnable: true });
165
167
  }
166
168
  }
@@ -190,6 +192,7 @@ export function createAgentRegistry({
190
192
  agent.id,
191
193
  agent.command,
192
194
  agent.name,
195
+ ...(Array.isArray(agent.aliases) ? agent.aliases : []),
193
196
  ].some(value => String(value || '').trim().toLowerCase() === needle)) || null;
194
197
  }
195
198
 
@@ -212,6 +215,7 @@ export function createAgentRegistry({
212
215
  agent.name,
213
216
  agent.description,
214
217
  agent.role,
218
+ ...(Array.isArray(agent.aliases) ? agent.aliases : []),
215
219
  ...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
216
220
  ...(Array.isArray(agent.domains) ? agent.domains : []),
217
221
  ].some(value => String(value || '').toLowerCase().includes(query));
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `bahulam mcp` — manage MCP server registrations.
3
+ *
4
+ * bahulam mcp add <name> --command <cmd> [--args ...] [--env KEY=VAL ...]
5
+ * bahulam mcp add <name> --url <url> [--headers ...]
6
+ * bahulam mcp remove <name>
7
+ * bahulam mcp list
8
+ * bahulam mcp test <name>
9
+ *
10
+ * MCP servers are stored in ~/.claude/settings.json under mcpServers,
11
+ * matching the Claude Desktop / Cursor / Cline portable format.
12
+ * The settings loader chain reads this file at startup, and the MCP
13
+ * loader (src/mcp/loader.mjs) spawns and registers tools from it.
14
+ */
15
+
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import * as os from 'node:os';
19
+
20
+ const RESET = '\x1b[0m';
21
+ const BOLD = '\x1b[1m';
22
+ const DIM = '\x1b[2m';
23
+ const CYAN = '\x1b[36m';
24
+ const GREEN = '\x1b[32m';
25
+ const YELLOW = '\x1b[33m';
26
+ const RED = '\x1b[31m';
27
+
28
+ function settingsFilePath() {
29
+ return path.join(os.homedir(), '.claude', 'settings.json');
30
+ }
31
+
32
+ function loadSettingsFile() {
33
+ const file = settingsFilePath();
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function saveSettingsFile(data) {
42
+ const file = settingsFilePath();
43
+ const dir = path.dirname(file);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ const parsed = {
50
+ subcommand: null,
51
+ name: null,
52
+ command: null,
53
+ args: [],
54
+ url: null,
55
+ env: {},
56
+ headers: {},
57
+ transport: null,
58
+ help: false,
59
+ json: false,
60
+ };
61
+ const positional = [];
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ switch (arg) {
65
+ case '--help': case '-h': parsed.help = true; break;
66
+ case '--json': parsed.json = true; break;
67
+ case '--command': parsed.command = argv[++i]; break;
68
+ case '--url': parsed.url = argv[++i]; break;
69
+ case '--transport': parsed.transport = argv[++i]; break;
70
+ case '--env': {
71
+ const pair = argv[++i] || '';
72
+ const eq = pair.indexOf('=');
73
+ if (eq > 0) parsed.env[pair.slice(0, eq)] = pair.slice(eq + 1);
74
+ break;
75
+ }
76
+ case '--header': case '--headers': {
77
+ const pair = argv[++i] || '';
78
+ const eq = pair.indexOf(':');
79
+ if (eq > 0) parsed.headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
80
+ break;
81
+ }
82
+ case '--args': {
83
+ // Collect remaining positional args until next --flag
84
+ while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
85
+ parsed.args.push(argv[++i]);
86
+ }
87
+ break;
88
+ }
89
+ default:
90
+ if (!arg.startsWith('-')) positional.push(arg);
91
+ break;
92
+ }
93
+ }
94
+ parsed.subcommand = positional.shift() || null;
95
+ parsed.name = positional.shift() || null;
96
+ return parsed;
97
+ }
98
+
99
+ export async function handleMcpCommand(argv) {
100
+ const args = parseArgs(argv);
101
+
102
+ if (args.help || !args.subcommand) {
103
+ process.stderr.write(`
104
+ ${BOLD}bahulam mcp <subcommand>${RESET}
105
+
106
+ Manage MCP (Model Context Protocol) server registrations.
107
+
108
+ ${BOLD}Subcommands:${RESET}
109
+
110
+ ${CYAN}add${RESET} <name> ${DIM}--command <cmd> [--args a b c] [--env KEY=VAL ...]${RESET}
111
+ Register a stdio MCP server (spawns a child process).
112
+
113
+ ${CYAN}add${RESET} <name> ${DIM}--url <url> [--header "Key: Val" ...]${RESET}
114
+ Register a remote MCP server (SSE, WebSocket, or Streamable HTTP).
115
+ Transport is auto-detected: ws:// → WebSocket, /sse → SSE, else sHTTP.
116
+
117
+ ${CYAN}remove${RESET} <name> Unregister an MCP server.
118
+ ${CYAN}list${RESET} List all registered MCP servers.
119
+ ${CYAN}test${RESET} <name> Connect, list tools, and call one.
120
+
121
+ ${BOLD}Examples:${RESET}
122
+
123
+ ${DIM}# Supabase MCP (stdio)${RESET}
124
+ ${CYAN}bahulam mcp add supabase --command npx --args -y @supabase/mcp-server-supabase --env SUPABASE_ACCESS_TOKEN=sbp_xxx${RESET}
125
+
126
+ ${DIM}# Remote SSE server${RESET}
127
+ ${CYAN}bahulam mcp add myapi --url https://api.example.com/sse --header "Authorization: Bearer token123"${RESET}
128
+
129
+ ${DIM}# List and test${RESET}
130
+ ${CYAN}bahulam mcp list${RESET}
131
+ ${CYAN}bahulam mcp test supabase${RESET}
132
+
133
+ ${BOLD}Config location:${RESET} ${settingsFilePath()}
134
+
135
+ `);
136
+ if (!args.subcommand) process.exit(1);
137
+ return;
138
+ }
139
+
140
+ switch (args.subcommand) {
141
+ case 'add': return handleAdd(args);
142
+ case 'remove': case 'rm': return handleRemove(args);
143
+ case 'list': case 'ls': return handleList(args);
144
+ case 'test': return handleTest(args);
145
+ default:
146
+ process.stderr.write(`${RED}✗${RESET} Unknown subcommand: ${args.subcommand}\n`);
147
+ process.stderr.write(`Run ${CYAN}bahulam mcp --help${RESET} for usage.\n`);
148
+ process.exit(1);
149
+ }
150
+ }
151
+
152
+ function handleAdd(args) {
153
+ if (!args.name) {
154
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp add <name> --command <cmd> | --url <url>\n`);
155
+ process.exit(1);
156
+ }
157
+ if (!args.command && !args.url) {
158
+ process.stderr.write(`${RED}✗${RESET} Either --command or --url is required.\n`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const settings = loadSettingsFile();
163
+ if (!settings.mcpServers) settings.mcpServers = {};
164
+
165
+ const config = {};
166
+ if (args.command) {
167
+ config.command = args.command;
168
+ if (args.args.length > 0) config.args = args.args;
169
+ }
170
+ if (args.url) config.url = args.url;
171
+ if (Object.keys(args.env).length > 0) config.env = args.env;
172
+ if (Object.keys(args.headers).length > 0) config.headers = args.headers;
173
+ if (args.transport) config.transport = args.transport;
174
+
175
+ settings.mcpServers[args.name] = config;
176
+ saveSettingsFile(settings);
177
+
178
+ if (args.json) {
179
+ process.stdout.write(JSON.stringify({ ok: true, name: args.name, config }) + '\n');
180
+ return;
181
+ }
182
+
183
+ const type = config.command ? 'stdio' : 'remote';
184
+ const endpoint = config.command || config.url;
185
+ process.stderr.write(`${GREEN}✓${RESET} Registered MCP server ${BOLD}${args.name}${RESET} (${type}: ${endpoint})\n`);
186
+ process.stderr.write(` ${DIM}config${RESET} ${settingsFilePath()}\n`);
187
+ process.stderr.write(` ${DIM}test${RESET} ${CYAN}bahulam mcp test ${args.name}${RESET}\n\n`);
188
+ }
189
+
190
+ function handleRemove(args) {
191
+ if (!args.name) {
192
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp remove <name>\n`);
193
+ process.exit(1);
194
+ }
195
+ const settings = loadSettingsFile();
196
+ if (!settings.mcpServers?.[args.name]) {
197
+ process.stderr.write(`${YELLOW}!${RESET} No MCP server named "${args.name}" is registered.\n`);
198
+ process.exit(1);
199
+ }
200
+ delete settings.mcpServers[args.name];
201
+ saveSettingsFile(settings);
202
+
203
+ if (args.json) {
204
+ process.stdout.write(JSON.stringify({ ok: true, removed: args.name }) + '\n');
205
+ return;
206
+ }
207
+ process.stderr.write(`${GREEN}✓${RESET} Removed MCP server ${BOLD}${args.name}${RESET}\n`);
208
+ }
209
+
210
+ function handleList(args) {
211
+ const settings = loadSettingsFile();
212
+ const servers = settings.mcpServers || {};
213
+ const names = Object.keys(servers);
214
+
215
+ if (names.length === 0) {
216
+ process.stderr.write(`${DIM}No MCP servers registered.${RESET}\n`);
217
+ process.stderr.write(`Add one: ${CYAN}bahulam mcp add <name> --command <cmd>${RESET}\n\n`);
218
+ return;
219
+ }
220
+
221
+ if (args.json) {
222
+ process.stdout.write(JSON.stringify({ servers }) + '\n');
223
+ return;
224
+ }
225
+
226
+ process.stderr.write(`${BOLD}MCP Servers${RESET} (${names.length}):\n`);
227
+ for (const name of names) {
228
+ const cfg = servers[name];
229
+ const type = cfg.command ? 'stdio' : 'remote';
230
+ const endpoint = cfg.command || cfg.url || 'unknown';
231
+ const toolCount = cfg._tools ? ` (${cfg._tools} tools)` : '';
232
+ process.stderr.write(` ${CYAN}${name.padEnd(20)}${RESET} ${type.padEnd(7)} ${endpoint}${toolCount}\n`);
233
+ }
234
+ process.stderr.write(`\n${DIM}Config: ${settingsFilePath()}${RESET}\n\n`);
235
+ }
236
+
237
+ async function handleTest(args) {
238
+ if (!args.name) {
239
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp test <name>\n`);
240
+ process.exit(1);
241
+ }
242
+
243
+ const settings = loadSettingsFile();
244
+ const config = settings.mcpServers?.[args.name];
245
+ if (!config) {
246
+ process.stderr.write(`${RED}✗${RESET} No MCP server named "${args.name}" is registered.\n`);
247
+ process.exit(1);
248
+ }
249
+
250
+ process.stderr.write(`${DIM}Connecting to ${args.name}…${RESET}\n`);
251
+
252
+ try {
253
+ const { McpClient } = await import('../mcp/client.mjs');
254
+ const { loadMcpServers } = await import('../mcp/loader.mjs');
255
+
256
+ // Use the loader to handle env expansion
257
+ const fakeExecutor = {
258
+ registerMcpTool: () => true,
259
+ unregisterMcpServer: () => 0,
260
+ };
261
+ const mcp = await loadMcpServers(fakeExecutor, { mcpServers: { [args.name]: config } });
262
+
263
+ if (mcp.clients.length === 0) {
264
+ process.stderr.write(`${RED}✗${RESET} Failed to connect to "${args.name}".\n`);
265
+ process.stderr.write(`${DIM}Check that the command/URL is correct and any required env vars are set.${RESET}\n`);
266
+ process.exit(1);
267
+ }
268
+
269
+ const client = mcp.clients[0].client;
270
+ const tools = client.tools;
271
+
272
+ process.stderr.write(`${GREEN}✓${RESET} Connected! Server: ${BOLD}${client.serverInfo?.serverInfo?.name || args.name}${RESET}\n`);
273
+ process.stderr.write(` ${DIM}tools${RESET} ${tools.length}\n`);
274
+ for (const t of tools) {
275
+ process.stderr.write(` ${CYAN}${t.name}${RESET} — ${(t.description || '').slice(0, 70)}\n`);
276
+ }
277
+
278
+ // Try calling the first tool that looks like a list/query
279
+ const listTool = tools.find(t =>
280
+ /list|query|search|get|fetch/i.test(t.name) && !/delete|update|create|insert|drop/i.test(t.name)
281
+ );
282
+
283
+ if (listTool) {
284
+ process.stderr.write(`\n${DIM}Testing tool: ${listTool.name}…${RESET}\n`);
285
+ const result = await client.callTool(listTool.name, {});
286
+ const preview = String(result).slice(0, 500);
287
+ process.stderr.write(`${GREEN}✓${RESET} Result: ${preview}${preview.length >= 500 ? '…' : ''}\n`);
288
+ }
289
+
290
+ await mcp.disconnectAll();
291
+ process.stderr.write(`\n${GREEN}✓${RESET} Test passed.\n\n`);
292
+ } catch (err) {
293
+ process.stderr.write(`${RED}✗${RESET} Test failed: ${err.message}\n`);
294
+ process.exit(1);
295
+ }
296
+ }
@@ -7,8 +7,9 @@
7
7
  * yet; if a bare name is given to `install`, we look it up in the
8
8
  * awesome-bahulam-plugins index (a plain JSON manifest hosted in the
9
9
  * community repo).
10
- * - Install target: `~/.bahulam/plugins/` by default, `.bahulam/plugins/`
11
- * with --project. Never global npm, never modifies the user's PATH.
10
+ * - Install target: `~/.bahulam/plugins/`. Always. Plugins have no project
11
+ * scope (skills have one, separately). Never global npm, never modifies
12
+ * the user's PATH.
12
13
  * - Disable: rename directory to `<name>.disabled`. The plugin registry
13
14
  * only scans directories with a valid manifest, so this hides the plugin
14
15
  * without deleting it. `enable` reverses the rename.
@@ -24,7 +25,7 @@ import { spawn } from 'node:child_process';
24
25
  import { parsePluginManifestFile } from '../plugins/manifest.mjs';
25
26
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
26
27
  import { parsePiSource } from '../plugins/pi-compose.mjs';
27
- import { bahulamHome } from '../core/paths.mjs';
28
+ import { bahulamHome, pluginDirs, pluginInstallDir } from '../core/paths.mjs';
28
29
 
29
30
  const RESET = '\x1b[0m';
30
31
  const BOLD = '\x1b[1m';
@@ -36,17 +37,20 @@ const RED = '\x1b[31m';
36
37
 
37
38
  const INSTALL_STAMP = '.bahulam-plugin.json';
38
39
 
39
- function searchDirs(cwd) {
40
- return [
41
- { scope: 'project', dir: path.join(cwd, '.bahulam', 'plugins') },
42
- { scope: 'global', dir: path.join(bahulamHome(), 'plugins') },
43
- ];
40
+ function searchDirs(_cwd) {
41
+ // One scope. `_cwd` is accepted but ignored so callers don't churn, and so
42
+ // nobody is tempted to reintroduce cwd-dependence here.
43
+ return pluginDirs().map(dir => ({ scope: 'global', dir }));
44
44
  }
45
45
 
46
- export function pluginTargetDir({ global, cwd }) {
47
- return global
48
- ? path.join(bahulamHome(), 'plugins')
49
- : path.join(cwd, '.bahulam', 'plugins');
46
+ /**
47
+ * Where a plugin install lands: always the global root.
48
+ *
49
+ * The old `{global, cwd}` options are accepted and ignored — `global: false`
50
+ * no longer selects a project directory, because there isn't one.
51
+ */
52
+ export function pluginTargetDir() {
53
+ return pluginInstallDir();
50
54
  }
51
55
 
52
56
  function readManifest(dir) {
@@ -73,8 +77,12 @@ function scanInstalled(cwd) {
73
77
  // else in the plugins/ dir is stray (readManifest returns null).
74
78
  if (!parsed && !disabled) continue;
75
79
  const stamp = readStamp(pluginDir);
76
- const agentSlugs = (parsed?.manifest?.config?.agents || [])
77
- .map(a => a.slug || a.name).filter(Boolean);
80
+ const agents = parsed?.manifest?.config?.agents || [];
81
+ const agentSlugs = agents.map(a => a.slug || a.name).filter(Boolean);
82
+ const entryAgentSlugs = agents
83
+ .filter(a => a.entry_agent === true)
84
+ .map(a => a.slug || a.name)
85
+ .filter(Boolean);
78
86
  found.push({
79
87
  scope,
80
88
  directory: pluginDir,
@@ -87,6 +95,7 @@ function scanInstalled(cwd) {
87
95
  views: parsed?.manifest?.config?.views?.length || 0,
88
96
  composes: parsed?.manifest?.config?.composes?.length || 0,
89
97
  agentSlugs,
98
+ entryAgentSlugs,
90
99
  disabled,
91
100
  origin: stamp?.origin || null,
92
101
  installed_at: stamp?.installed_at || null,
@@ -433,11 +442,12 @@ async function cmdList(args, cwd) {
433
442
  const plugins = scanInstalled(cwd);
434
443
  const pi = scanPiIngredients();
435
444
  const allowlist = new Set(await readAgentAllowlist(cwd));
436
- // A pack is "enabled" for this session when at least one of its
437
- // agents is in plugins.agent_allowlist (PRD-102 §6.2.1). Packs with
438
- // no agents (tools-only packs) always count as enabled — the
439
- // allowlist gate only exists for agents.
440
- const enabled = (p) => p.agentSlugs.length === 0 || p.agentSlugs.some(s => allowlist.has(s));
445
+ // A pack is "enabled" for this session when it has no agents, exposes an
446
+ // entry agent, or has at least one agent explicitly allowlisted. Helpers
447
+ // without entry_agent stay workspace-scoped until allowlisted.
448
+ const enabled = (p) => p.agentSlugs.length === 0
449
+ || (p.entryAgentSlugs || []).length > 0
450
+ || p.agentSlugs.some(s => allowlist.has(s));
441
451
  const composerFor = (piName) => plugins.filter(p =>
442
452
  (p.composes > 0) && Boolean(p) // composes is a count; details need re-read
443
453
  );
@@ -460,6 +470,7 @@ async function cmdList(args, cwd) {
460
470
  ...p,
461
471
  enabled: enabled(p),
462
472
  allowlisted_agents: p.agentSlugs.filter(s => allowlist.has(s)),
473
+ entry_agents: p.entryAgentSlugs || [],
463
474
  }));
464
475
  process.stdout.write(JSON.stringify({
465
476
  ok: true,
@@ -497,7 +508,7 @@ async function cmdList(args, cwd) {
497
508
  const notEnabled = plugins.filter(p => !p.disabled && !enabled(p));
498
509
  if (notEnabled.length) {
499
510
  process.stderr.write(`\n${YELLOW}!${RESET} ${notEnabled.length} pack${notEnabled.length === 1 ? '' : 's'} installed but NOT enabled in this session.\n`);
500
- process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted.\n`);
511
+ process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted or declared as entry_agent.\n`);
501
512
  process.stderr.write(` Add to ${CYAN}.bahulam/settings.json${RESET}:\n`);
502
513
  const slugs = notEnabled.flatMap(p => p.agentSlugs);
503
514
  process.stderr.write(` ${DIM}{ "plugins": { "agent_allowlist": ${JSON.stringify(slugs)} } }${RESET}\n`);
@@ -31,13 +31,10 @@ const YELLOW = '\x1b[33m';
31
31
  const RED = '\x1b[31m';
32
32
 
33
33
  /**
34
- * Standard directories to search for plugins.
34
+ * The one and only directory to search for plugins.
35
35
  */
36
- function pluginSearchDirs(cwd = process.cwd()) {
37
- return [
38
- path.join(cwd, '.bahulam', 'plugins'),
39
- path.join(bahulamHome(), 'plugins'),
40
- ];
36
+ function pluginSearchDirs() {
37
+ return [path.join(bahulamHome(), 'plugins')];
41
38
  }
42
39
 
43
40
  /**
@@ -18,6 +18,8 @@ import { persistProjectArtifacts } from './project-artifacts.mjs';
18
18
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
19
19
  import { ApprovalManager } from './approval.mjs';
20
20
  import { PluginRegistry } from '../plugins/registry.mjs';
21
+ import { loadSettings } from '../config/settings.mjs';
22
+ import { loadMcpServers } from '../mcp/loader.mjs';
21
23
  // daemon wiring — headless (and `bahulam daemonize`) also starts the socket
22
24
  // server + relay bridge when eventlog is enabled. Without this the daemon
23
25
  // is invisible to attach clients and to paired mobile devices.
@@ -98,6 +100,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
98
100
  return outcome;
99
101
  };
100
102
  toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
103
+ // Load MCP servers from settings chain (~/.claude/settings.json etc.)
104
+ const _settings = await loadSettings();
105
+ const _mcpClients = await loadMcpServers(toolExecutor, _settings);
101
106
  const timer = setTimeout(() => {
102
107
  emit({ type: 'timeout', duration_s: timeout });
103
108
  process.exit(2);
@@ -167,6 +172,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
167
172
  };
168
173
  toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
169
174
 
175
+ // Load MCP servers from settings chain (~/.claude/settings.json etc.)
176
+ const _settings = await loadSettings();
177
+ const _mcpClients = await loadMcpServers(toolExecutor, _settings);
178
+
170
179
  // Auto-approve everything — no prompts
171
180
  const approval = new ApprovalManager({ autoApprove: true });
172
181
 
@@ -7,6 +7,9 @@
7
7
  * history.jsonl — prompt history
8
8
  * hooks.json — global hooks
9
9
  * conversations/ — conversation JSONL files
10
+ * plugins/ — installed plugins (THE plugin root)
11
+ * plugins-pi/ — pi ingredients (composed, not run directly)
12
+ * data/{plugin}/ — per-plugin state (state.db)
10
13
  * projects/
11
14
  * {hash}/ — per-project data (hash of project path)
12
15
  * index/ — BM25 search index
@@ -57,6 +60,27 @@ export function bahulamHome() {
57
60
  return resolveHome();
58
61
  }
59
62
 
63
+ /**
64
+ * The one and only plugin root: `~/.bahulam/plugins`.
65
+ *
66
+ * Deliberately takes no `cwd`. There used to be a project-scoped second root
67
+ * (`<cwd>/.bahulam/plugins`), which meant the set of installed plugins
68
+ * depended on the directory you happened to launch from — the same command
69
+ * saw different plugins in different terminals. Plugins are global; only
70
+ * skills keep a project scope.
71
+ */
72
+ export function pluginInstallDir() {
73
+ return path.join(bahulamHome(), 'plugins');
74
+ }
75
+
76
+ /**
77
+ * Directories the plugin registry scans. Exactly one entry, forever.
78
+ * @returns {string[]}
79
+ */
80
+ export function pluginDirs() {
81
+ return [pluginInstallDir()];
82
+ }
83
+
60
84
  /** ~/.bahulam/projects/{hash}/ for a given project path. */
61
85
  export function projectDir(projectPath) {
62
86
  return path.join(bahulamHome(), 'projects', projectHash(projectPath));
@@ -203,12 +203,45 @@ export class BahulamStreamClient {
203
203
  }
204
204
 
205
205
  /**
206
- * Plugin tools are intentionally not advertised as primary client_tools.
207
- * They are executable by the local callback handler, but the primary model
208
- * should reach them by delegating to an agent that declares them.
206
+ * Plugin tool schemas for tools that have NO owning agent.
207
+ * These are advertised as direct client_tools so the primary model can
208
+ * call them without going through delegate(). Tools already claimed by
209
+ * a plugin agent stay in client_agent_tools and are not duplicated.
209
210
  */
210
- _getPluginToolSchemas() {
211
- return [];
211
+ _getUnclaimedPluginToolSchemas(context = {}, clientAgents = []) {
212
+ if (!this.pluginRegistry) return [];
213
+ const pluginTools = this._getPluginToolMap();
214
+ if (!pluginTools.size) return [];
215
+
216
+ // Collect tool names declared by plugin agents that are actually
217
+ // advertised. Tools owned only by hidden/workspace-scoped helpers still
218
+ // need to be exposed as direct client_tools.
219
+ const clientAgentSlugs = new Set(this._getPluginAgentSchemas().map(a => a.slug));
220
+ const agentToolNames = new Set();
221
+ for (const agent of (this.pluginRegistry.listAgents?.() || [])) {
222
+ const slug = agent.slug || agent.name || '';
223
+ if (!clientAgentSlugs.has(slug)) continue;
224
+ for (const t of (agent.tools || [])) {
225
+ if (t) agentToolNames.add(String(t).trim());
226
+ }
227
+ }
228
+ for (const name of this._collectAgentScopedToolRefs(context, clientAgents).keys()) {
229
+ agentToolNames.add(name);
230
+ }
231
+
232
+ // Return schemas for tools NOT claimed by any agent
233
+ const schemas = [];
234
+ for (const [name, tool] of pluginTools) {
235
+ if (agentToolNames.has(name)) continue; // already in client_agent_tools
236
+ schemas.push({
237
+ name,
238
+ description: tool.description || '',
239
+ input_schema: tool.input_schema || { type: 'object', properties: {} },
240
+ source_scope: 'plugin',
241
+ plugin_name: tool._plugin_name || tool.plugin_name || null,
242
+ });
243
+ }
244
+ return schemas;
212
245
  }
213
246
 
214
247
  _collectAgentScopedToolRefs(context = {}, clientAgents = []) {
@@ -263,11 +296,11 @@ export class BahulamStreamClient {
263
296
  */
264
297
  _getPluginAgentSchemas() {
265
298
  if (!this.pluginRegistry) return [];
266
- // Only plugin agents admitted to the main-loop registry (settings
267
- // plugins.agent_allowlist, or the session plugin in workspace-channel
268
- // executors) are advertised. Workspace-scoped plugin agents stay out
269
- // of the main-turn payload; without an executor registry, fall back
270
- // to advertising everything (legacy behavior).
299
+ // Only plugin agents admitted to the runtime registry are advertised:
300
+ // entry agents, settings plugins.agent_allowlist, or all plugin agents
301
+ // in workspace-channel executors. Other helpers stay out of the
302
+ // main-turn payload; without an executor registry, fall back to
303
+ // advertising everything (legacy behavior).
271
304
  const runnables = this.toolExecutor?.listRunnables?.();
272
305
  const admitted = Array.isArray(runnables)
273
306
  ? new Set(runnables.filter(a => a.source_scope === 'plugin').map(a => a.slug))
@@ -365,10 +398,13 @@ export class BahulamStreamClient {
365
398
  const body = { instruction, context };
366
399
  if (messages && messages.length > 0) body.messages = messages;
367
400
  if (this.sessionId) body.session_id = this.sessionId;
368
- const clientTools = this._getPluginToolSchemas();
369
- if (clientTools.length > 0) body.client_tools = clientTools;
401
+ // Plugin tools with no owning agent are advertised as direct client_tools
402
+ // so the primary model can call them. Agent-scoped tools stay in
403
+ // client_agent_tools — _getUnclaimedPluginToolSchemas excludes those.
370
404
  const clientAgents = this._getPluginAgentSchemas();
371
405
  if (clientAgents.length > 0) body.client_agents = clientAgents;
406
+ const clientTools = this._getUnclaimedPluginToolSchemas(context, clientAgents);
407
+ if (clientTools.length > 0) body.client_tools = clientTools;
372
408
  const clientAgentTools = this._getClientAgentToolSchemas(context, clientAgents);
373
409
  if (clientAgentTools.length > 0) body.client_agent_tools = clientAgentTools;
374
410
  const requestId = `cli-${_uuidLike()}`;