@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
package/package.json
CHANGED
package/src/agents/loader.mjs
CHANGED
|
@@ -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,
|
package/src/agents/parser.mjs
CHANGED
|
@@ -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 || {},
|
package/src/agents/registry.mjs
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
77
|
-
|
|
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
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
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`);
|
package/src/commands/plugin.mjs
CHANGED
|
@@ -31,13 +31,10 @@ const YELLOW = '\x1b[33m';
|
|
|
31
31
|
const RED = '\x1b[31m';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* The one and only directory to search for plugins.
|
|
35
35
|
*/
|
|
36
|
-
function pluginSearchDirs(
|
|
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
|
/**
|
package/src/core/headless.mjs
CHANGED
|
@@ -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
|
|
package/src/core/paths.mjs
CHANGED
|
@@ -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
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
-
|
|
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
|
|
267
|
-
// plugins.agent_allowlist, or
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
-
|
|
369
|
-
|
|
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()}`;
|