@botbuddy/cli 1.0.0

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,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/commands.mjs";
3
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@botbuddy/cli",
3
+ "version": "1.0.0",
4
+ "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
+ "type": "module",
6
+ "bin": {
7
+ "botbuddy": "./bin/botbuddy.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "keywords": ["mcp", "agents", "swarm", "cli", "coordination"],
14
+ "license": "MIT",
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ }
21
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import { getConfig, SERVER_URL } from "./config.mjs";
2
+ import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
3
+
4
+ function authHeader() {
5
+ const cfg = getConfig();
6
+ if (cfg.access_token) {
7
+ if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) {
8
+ die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
9
+ }
10
+ if (cfg.token_expires_at && cfg.token_expires_at - Date.now() < 5 * 60 * 1000) {
11
+ console.error(`${yellow("⚠")} Token expires in <5 minutes. Run ${cyan("botbuddy login")} soon.`);
12
+ }
13
+ return { Authorization: `Bearer ${cfg.access_token}` };
14
+ }
15
+ if (cfg.api_key) return { "x-agent-api-key": cfg.api_key };
16
+ die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
17
+ }
18
+
19
+ export async function callTool(toolName, args = {}) {
20
+ const headers = { "Content-Type": "application/json", ...authHeader() };
21
+ const body = {
22
+ jsonrpc: "2.0",
23
+ id: 1,
24
+ method: "tools/call",
25
+ params: { name: toolName, arguments: args },
26
+ };
27
+
28
+ const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
29
+ const data = await res.json();
30
+
31
+ if (data.error?.message) die(`Server error: ${data.error.message}`);
32
+
33
+ const text = data.result?.content?.map((c) => c.text).join("\n");
34
+ if (text) {
35
+ console.log(prettyJson(text));
36
+ } else {
37
+ console.log(JSON.stringify(data, null, 2));
38
+ }
39
+ return data;
40
+ }
41
+
42
+ export async function listResources() {
43
+ const headers = { "Content-Type": "application/json", ...authHeader() };
44
+ const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
45
+ const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
46
+ const data = await res.json();
47
+ console.log(JSON.stringify(data.result?.resources ?? data.result ?? data, null, 2));
48
+ }
49
+
50
+ export async function readResource(uri) {
51
+ const headers = { "Content-Type": "application/json", ...authHeader() };
52
+ const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
53
+ const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
54
+ const data = await res.json();
55
+ const text = data.result?.contents?.[0]?.text;
56
+ if (text) {
57
+ console.log(prettyJson(text));
58
+ } else {
59
+ console.log(JSON.stringify(data.result ?? data, null, 2));
60
+ }
61
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ import { SERVER_URL, saveConfig, getConfig } from "./config.mjs";
3
+ import { green, dim, die } from "./utils.mjs";
4
+
5
+ export async function doLogin() {
6
+ console.log("\x1b[1mBotBuddy OAuth Login\x1b[0m\n");
7
+
8
+ // Step 1: Dynamic client registration
9
+ console.log(dim("→ Registering client..."));
10
+ const regRes = await fetch(`${SERVER_URL}/register`, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify({
14
+ client_name: "botbuddy-cli-node",
15
+ redirect_uris: ["http://localhost:19836/callback"],
16
+ grant_types: ["authorization_code"],
17
+ token_endpoint_auth_method: "none",
18
+ }),
19
+ });
20
+ const regData = await regRes.json();
21
+ const clientId = regData.client_id;
22
+ if (!clientId) die(`Client registration failed: ${JSON.stringify(regData)}`);
23
+ console.log(` ${green("✓")} Client registered: ${dim(clientId)}`);
24
+
25
+ // Step 2: Generate PKCE
26
+ const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
27
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
28
+
29
+ // Step 3: Authorization
30
+ const state = randomBytes(16).toString("hex");
31
+ const authUrl = `${SERVER_URL}/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent("http://localhost:19836/callback")}&response_type=code&scope=read+write+lock&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
32
+
33
+ console.log(dim("→ Requesting authorization..."));
34
+ const authRes = await fetch(authUrl, { redirect: "manual" });
35
+ const location = authRes.headers.get("location");
36
+ if (!location) die("Authorization failed — no redirect received");
37
+
38
+ const codeMatch = location.match(/code=([^&]+)/);
39
+ if (!codeMatch) die("No authorization code in redirect");
40
+ const authCode = codeMatch[1];
41
+ console.log(` ${green("✓")} Authorization code received`);
42
+
43
+ // Step 4: Exchange code for token
44
+ console.log(dim("→ Exchanging code for token..."));
45
+ const tokenRes = await fetch(`${SERVER_URL}/token`, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
+ body: new URLSearchParams({
49
+ grant_type: "authorization_code",
50
+ code: authCode,
51
+ client_id: clientId,
52
+ code_verifier: codeVerifier,
53
+ redirect_uri: "http://localhost:19836/callback",
54
+ }),
55
+ });
56
+ const tokenData = await tokenRes.json();
57
+ if (!tokenData.access_token) die(`Token exchange failed: ${JSON.stringify(tokenData)}`);
58
+ console.log(` ${green("✓")} Access token received`);
59
+
60
+ const expiresAt = tokenData.expires_in
61
+ ? Date.now() + tokenData.expires_in * 1000
62
+ : Date.now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
63
+
64
+ saveConfig({
65
+ ...getConfig(),
66
+ access_token: tokenData.access_token,
67
+ client_id: clientId,
68
+ token_expires_at: expiresAt,
69
+ });
70
+
71
+ console.log(`\n${green("✓")} Logged in successfully! Token saved to ${dim("~/.botbuddy/config.json")}`);
72
+ }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * BotBuddy Codex Bridge
3
+ *
4
+ * Runs locally alongside `codex app-server --listen ws://127.0.0.1:<port>`.
5
+ * Connects the local Codex app-server to BotBuddy for remote orchestration.
6
+ *
7
+ * Security:
8
+ * - Bridge ↔ BotBuddy: HTTPS + HMAC session signing (prevents session hijacking)
9
+ * - Bridge ↔ Codex app-server: localhost-only WS with nonce handshake
10
+ *
11
+ * Usage:
12
+ * botbuddy codex bridge [--port 4500] [--repo /path/to/repo] [--model gpt-5.4]
13
+ */
14
+
15
+ import { createHash, randomBytes } from "crypto";
16
+ import { getConfig, SERVER_URL } from "./config.mjs";
17
+ import { green, red, cyan, dim, bold, yellow, die } from "./utils.mjs";
18
+
19
+ const RELAY_URL = SERVER_URL.replace("/mcp-server", "/codex-relay");
20
+ const POLL_INTERVAL_MS = 2000;
21
+ const PING_INTERVAL_MS = 15000;
22
+
23
+ // ─── HMAC signing ───────────────────────────────────────────────
24
+ let sessionSecret = null;
25
+
26
+ // Lazy async HMAC that matches the server's Web Crypto implementation
27
+
28
+ // Lazy async HMAC that matches the server's Web Crypto implementation
29
+ async function signRequest(sessionId) {
30
+ if (!sessionSecret) return {};
31
+ const timestamp = String(Date.now());
32
+ const { createHmac } = await import("crypto");
33
+ const hmac = createHmac("sha256", sessionSecret).update(`${sessionId}:${timestamp}`).digest("hex");
34
+ return {
35
+ "x-bb-hmac": hmac,
36
+ "x-bb-timestamp": timestamp,
37
+ };
38
+ }
39
+
40
+ export async function runBridge(args) {
41
+ const cfg = getConfig();
42
+ if (!cfg.api_key && !cfg.access_token) {
43
+ die(`Not authenticated. Run: ${cyan("botbuddy login")} or ${cyan("botbuddy register <name>")}`);
44
+ }
45
+
46
+ // Parse args
47
+ let wsPort = 4500;
48
+ let repoPath = process.cwd();
49
+ let model = "gpt-5.4";
50
+ let autoStart = false;
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ if ((args[i] === "--port" || args[i] === "-p") && args[i + 1]) wsPort = parseInt(args[++i], 10);
54
+ else if ((args[i] === "--repo" || args[i] === "-r") && args[i + 1]) repoPath = args[++i];
55
+ else if ((args[i] === "--model" || args[i] === "-m") && args[i + 1]) model = args[++i];
56
+ else if (args[i] === "--auto-start") autoStart = true;
57
+ }
58
+
59
+ const wsUrl = `ws://127.0.0.1:${wsPort}`;
60
+ const hostname = (await import("os")).then(m => m.hostname());
61
+ const host = await hostname;
62
+
63
+ console.log(`${bold("BotBuddy Codex Bridge")}\n`);
64
+ console.log(` ${dim("WebSocket:")} ${cyan(wsUrl)}`);
65
+ console.log(` ${dim("Repo:")} ${cyan(repoPath)}`);
66
+ console.log(` ${dim("Model:")} ${cyan(model)}`);
67
+ console.log(` ${dim("Host:")} ${dim(host)}`);
68
+ console.log(` ${dim("Security:")} ${green("HMAC-SHA256")} session signing + ${green("loopback-only")} WS\n`);
69
+
70
+ // ── Step 1: Register bridge session with BotBuddy ──
71
+ console.log(dim("→ Connecting to BotBuddy..."));
72
+ const session = await relayPost("/bridge/connect", {
73
+ bridge_name: cfg.agent_name || `bridge-${host}`,
74
+ machine_host: host,
75
+ repo_path: repoPath,
76
+ config: { ws_port: wsPort, model, auto_start: autoStart },
77
+ });
78
+
79
+ if (!session?.session?.id) die("Failed to register bridge session");
80
+ const sessionId = session.session.id;
81
+
82
+ // Store session secret for HMAC signing
83
+ if (session.session_secret) {
84
+ sessionSecret = session.session_secret;
85
+ console.log(` ${green("✓")} HMAC session secret received`);
86
+ } else {
87
+ console.log(` ${yellow("⚠")} No session secret — HMAC signing disabled`);
88
+ }
89
+ console.log(` ${green("✓")} Bridge session: ${dim(sessionId)}\n`);
90
+
91
+ // ── Step 2: Connect to local Codex app-server ──
92
+ let ws = null;
93
+ let wsConnected = false;
94
+ let msgId = 100;
95
+ let initialized = false;
96
+ const pendingRequests = new Map(); // id → { resolve, reject }
97
+ const threads = new Map(); // codex_thread_id → bb_thread_id
98
+
99
+ // Generate a nonce for localhost verification
100
+ const bridgeNonce = randomBytes(16).toString("hex");
101
+
102
+ function connectWs() {
103
+ console.log(dim(`→ Connecting to Codex app-server at ${wsUrl}...`));
104
+ try {
105
+ ws = new WebSocket(wsUrl);
106
+ } catch (err) {
107
+ console.log(` ${red("✗")} WebSocket connection failed: ${err.message}`);
108
+ console.log(` ${dim("Make sure codex app-server is running:")}`);
109
+ console.log(` ${cyan(`codex app-server --listen ${wsUrl}`)}\n`);
110
+ setTimeout(connectWs, 5000);
111
+ return;
112
+ }
113
+
114
+ ws.onopen = async () => {
115
+ wsConnected = true;
116
+ console.log(` ${green("✓")} Connected to Codex app-server`);
117
+
118
+ // Verify we're actually on localhost (defense-in-depth)
119
+ try {
120
+ const wsUrlObj = new URL(wsUrl.replace("ws://", "http://"));
121
+ const resolvedHost = wsUrlObj.hostname;
122
+ if (resolvedHost !== "127.0.0.1" && resolvedHost !== "localhost" && resolvedHost !== "::1") {
123
+ console.log(` ${red("✗")} SECURITY: Refusing non-loopback connection to ${resolvedHost}`);
124
+ ws.close();
125
+ return;
126
+ }
127
+ } catch {}
128
+
129
+ // Initialize handshake with bridge identity nonce
130
+ const initResult = await sendWs("initialize", {
131
+ clientInfo: {
132
+ name: "botbuddy_bridge",
133
+ title: "BotBuddy Codex Bridge",
134
+ version: "1.0.0",
135
+ bridgeNonce,
136
+ },
137
+ capabilities: { experimentalApi: true },
138
+ });
139
+ console.log(` ${green("✓")} Initialized: platform=${initResult?.platformFamily || "unknown"}`);
140
+
141
+ // Send initialized notification
142
+ ws.send(JSON.stringify({ method: "initialized", params: {} }));
143
+ initialized = true;
144
+ console.log(` ${green("✓")} Handshake complete — ready for commands\n`);
145
+ };
146
+
147
+ ws.onmessage = (event) => {
148
+ try {
149
+ const msg = JSON.parse(event.data);
150
+ handleAppServerMessage(msg);
151
+ } catch (err) {
152
+ console.error("Failed to parse app-server message:", err);
153
+ }
154
+ };
155
+
156
+ ws.onclose = () => {
157
+ wsConnected = false;
158
+ initialized = false;
159
+ console.log(`\n ${red("✗")} Codex app-server disconnected. Reconnecting in 5s...`);
160
+ setTimeout(connectWs, 5000);
161
+ };
162
+
163
+ ws.onerror = (err) => {
164
+ console.error(` ${red("✗")} WebSocket error`);
165
+ };
166
+ }
167
+
168
+ function sendWs(method, params = {}) {
169
+ return new Promise((resolve, reject) => {
170
+ const id = msgId++;
171
+ pendingRequests.set(id, { resolve, reject });
172
+ const msg = { method, id, params };
173
+ ws.send(JSON.stringify(msg));
174
+ // Timeout after 30s
175
+ setTimeout(() => {
176
+ if (pendingRequests.has(id)) {
177
+ pendingRequests.delete(id);
178
+ reject(new Error(`Timeout waiting for response to ${method}`));
179
+ }
180
+ }, 30000);
181
+ });
182
+ }
183
+
184
+ async function handleAppServerMessage(msg) {
185
+ // Response to a request we made
186
+ if (msg.id !== undefined && pendingRequests.has(msg.id)) {
187
+ const { resolve, reject } = pendingRequests.get(msg.id);
188
+ pendingRequests.delete(msg.id);
189
+ if (msg.error) reject(new Error(msg.error.message));
190
+ else resolve(msg.result);
191
+ return;
192
+ }
193
+
194
+ // Notification from app-server
195
+ if (msg.method) {
196
+ const eventType = msg.method;
197
+ const payload = msg.params || {};
198
+
199
+ // Handle thread lifecycle
200
+ if (eventType === "thread/started") {
201
+ const threadId = payload.thread?.id;
202
+ if (threadId) {
203
+ console.log(` ${cyan("◆")} Thread started: ${threadId}`);
204
+ const result = await relayPost("/bridge/thread-update", {
205
+ session_id: sessionId,
206
+ codex_thread_id: threadId,
207
+ status: "active",
208
+ model,
209
+ });
210
+ if (result?.thread_id) threads.set(threadId, result.thread_id);
211
+ }
212
+ }
213
+
214
+ if (eventType === "turn/started") {
215
+ console.log(` ${cyan("▸")} Turn started: ${payload.turn?.id || "?"}`);
216
+ }
217
+
218
+ if (eventType === "turn/completed") {
219
+ const status = payload.turn?.status || "completed";
220
+ console.log(` ${status === "completed" ? green("✓") : red("✗")} Turn ${payload.turn?.id}: ${status}`);
221
+
222
+ // Extract and forward token usage if present
223
+ const usage = payload.turn?.usage || payload.usage;
224
+ if (usage) {
225
+ const threadId = payload.thread?.id || payload.threadId;
226
+ if (threadId) {
227
+ const updatePayload = { session_id: sessionId, codex_thread_id: threadId };
228
+ if (usage.input_tokens !== undefined) updatePayload.input_tokens = usage.input_tokens;
229
+ if (usage.output_tokens !== undefined) updatePayload.output_tokens = usage.output_tokens;
230
+ if (usage.total_tokens !== undefined) {
231
+ updatePayload.input_tokens = updatePayload.input_tokens || 0;
232
+ updatePayload.output_tokens = updatePayload.output_tokens || (usage.total_tokens - (updatePayload.input_tokens || 0));
233
+ }
234
+ if (usage.cost_cents !== undefined) updatePayload.total_cost_cents = usage.cost_cents;
235
+ await relayPost("/bridge/thread-update", updatePayload).catch(() => {});
236
+ console.log(` ${dim(`tokens: ${usage.input_tokens || 0}↑ ${usage.output_tokens || 0}↓`)}`);
237
+ }
238
+ }
239
+ }
240
+
241
+ if (eventType === "item/agentMessage/delta") {
242
+ const text = payload.delta?.text || "";
243
+ if (text) process.stdout.write(dim(text));
244
+ }
245
+
246
+ if (eventType === "item/started") {
247
+ const itemType = payload.item?.type;
248
+ if (itemType === "commandRun") {
249
+ console.log(`\n ${cyan("$")} ${payload.item?.command || ""}`);
250
+ }
251
+ }
252
+
253
+ // Capture rate limits from codex app-server
254
+ if (eventType === "codex.rate_limits") {
255
+ console.log(` ${dim("⟳ Rate limits updated")}`);
256
+ await relayPost("/bridge/rate-limits", {
257
+ session_id: sessionId,
258
+ rate_limits: payload,
259
+ }).catch(() => {});
260
+ }
261
+
262
+ if (eventType === "thread/status/changed") {
263
+ const threadId = payload.threadId;
264
+ const status = payload.status?.type;
265
+ if (threadId && status) {
266
+ await relayPost("/bridge/thread-update", {
267
+ session_id: sessionId,
268
+ codex_thread_id: threadId,
269
+ status,
270
+ });
271
+ }
272
+ }
273
+
274
+ // Forward event to BotBuddy
275
+ const bbThreadId = payload.thread?.id ? threads.get(payload.thread.id) :
276
+ payload.threadId ? threads.get(payload.threadId) : null;
277
+ if (bbThreadId) {
278
+ await relayPost("/bridge/event", {
279
+ session_id: sessionId,
280
+ thread_id: bbThreadId,
281
+ event_type: eventType,
282
+ turn_id: payload.turn?.id || payload.turnId,
283
+ item_id: payload.item?.id,
284
+ payload,
285
+ }).catch(() => {}); // non-critical
286
+ }
287
+ }
288
+ }
289
+
290
+ // ── Step 3: Poll for commands from BotBuddy ──
291
+ async function pollCommands() {
292
+ try {
293
+ const result = await relayGet("/bridge/command-poll");
294
+ const commands = result?.commands || [];
295
+ for (const cmd of commands) {
296
+ await executeCommand(cmd);
297
+ }
298
+ } catch (err) {
299
+ // Silently retry
300
+ }
301
+ }
302
+
303
+ async function executeCommand(cmd) {
304
+ console.log(`\n ${cyan("⟐")} Command: ${cmd.command} ${JSON.stringify(cmd.params)}`);
305
+
306
+ try {
307
+ if (!wsConnected || !initialized) {
308
+ await relayPost("/bridge/command-result", {
309
+ command_id: cmd.id,
310
+ status: "failed",
311
+ result: { error: "Codex app-server not connected" },
312
+ });
313
+ return;
314
+ }
315
+
316
+ let result;
317
+
318
+ switch (cmd.command) {
319
+ case "thread/start": {
320
+ result = await sendWs("thread/start", {
321
+ model: cmd.params.model || model,
322
+ cwd: cmd.params.cwd || repoPath,
323
+ approvalPolicy: cmd.params.approval_policy || "never",
324
+ sandbox: cmd.params.sandbox || "workspaceWrite",
325
+ });
326
+ // Track thread
327
+ if (result?.thread?.id) {
328
+ const bbResult = await relayPost("/bridge/thread-update", {
329
+ session_id: sessionId,
330
+ codex_thread_id: result.thread.id,
331
+ title: cmd.params.title,
332
+ model: cmd.params.model || model,
333
+ status: "idle",
334
+ cwd: cmd.params.cwd || repoPath,
335
+ });
336
+ if (bbResult?.thread_id) threads.set(result.thread.id, bbResult.thread_id);
337
+ }
338
+ break;
339
+ }
340
+
341
+ case "turn/start": {
342
+ const codexThreadId = await resolveCodexThreadId(cmd.thread_id);
343
+ if (!codexThreadId) {
344
+ result = { error: "Thread not found" };
345
+ break;
346
+ }
347
+ result = await sendWs("turn/start", {
348
+ threadId: codexThreadId,
349
+ input: [{ type: "text", text: cmd.params.prompt || cmd.params.text || "" }],
350
+ ...(cmd.params.model && { model: cmd.params.model }),
351
+ });
352
+ break;
353
+ }
354
+
355
+ case "turn/interrupt": {
356
+ const codexTid = await resolveCodexThreadId(cmd.thread_id);
357
+ if (!codexTid) { result = { error: "Thread not found" }; break; }
358
+ result = await sendWs("turn/interrupt", {
359
+ threadId: codexTid,
360
+ turnId: cmd.params.turn_id,
361
+ });
362
+ break;
363
+ }
364
+
365
+ case "thread/list": {
366
+ result = await sendWs("thread/list", {
367
+ limit: cmd.params.limit || 25,
368
+ });
369
+ break;
370
+ }
371
+
372
+ default:
373
+ // Pass through any other app-server method
374
+ result = await sendWs(cmd.command, cmd.params);
375
+ }
376
+
377
+ await relayPost("/bridge/command-result", {
378
+ command_id: cmd.id,
379
+ status: "completed",
380
+ result,
381
+ });
382
+ console.log(` ${green("✓")} Command completed`);
383
+ } catch (err) {
384
+ console.error(` ${red("✗")} Command failed: ${err.message}`);
385
+ await relayPost("/bridge/command-result", {
386
+ command_id: cmd.id,
387
+ status: "failed",
388
+ result: { error: err.message },
389
+ });
390
+ }
391
+ }
392
+
393
+ async function resolveCodexThreadId(bbThreadId) {
394
+ // Look up in local map first
395
+ for (const [codexId, bbId] of threads.entries()) {
396
+ if (bbId === bbThreadId) return codexId;
397
+ }
398
+ // Fallback: query DB via relay
399
+ return null;
400
+ }
401
+
402
+ // ── Step 4: Ping + command poll loops ──
403
+ const pingInterval = setInterval(async () => {
404
+ try {
405
+ await relayPost("/bridge/ping", { session_id: sessionId });
406
+ } catch {}
407
+ }, PING_INTERVAL_MS);
408
+
409
+ const pollInterval = setInterval(pollCommands, POLL_INTERVAL_MS);
410
+
411
+ // ── Step 5: Graceful shutdown ──
412
+ const shutdown = async () => {
413
+ console.log(`\n${dim("→ Shutting down bridge...")}`);
414
+ clearInterval(pingInterval);
415
+ clearInterval(pollInterval);
416
+ try {
417
+ await relayPost("/bridge/disconnect", { session_id: sessionId });
418
+ } catch {}
419
+ if (ws) ws.close();
420
+ console.log(`${green("✓")} Bridge disconnected.`);
421
+ process.exit(0);
422
+ };
423
+
424
+ process.on("SIGINT", shutdown);
425
+ process.on("SIGTERM", shutdown);
426
+
427
+ // ── Start ──
428
+ connectWs();
429
+ console.log(dim("Polling for commands from BotBuddy...\n"));
430
+ }
431
+
432
+ // ─── HTTP helpers ───
433
+
434
+ function authHeaders() {
435
+ const cfg = getConfig();
436
+ const headers = { "Content-Type": "application/json" };
437
+ if (cfg.api_key) headers["x-agent-api-key"] = cfg.api_key;
438
+ else if (cfg.access_token) headers["Authorization"] = `Bearer ${cfg.access_token}`;
439
+ return headers;
440
+ }
441
+
442
+ async function relayPost(path, body) {
443
+ const headers = authHeaders();
444
+ // Add HMAC signing if we have a session secret and session_id
445
+ if (sessionSecret && body?.session_id) {
446
+ const hmacHeaders = await signRequest(body.session_id);
447
+ Object.assign(headers, hmacHeaders);
448
+ }
449
+ const res = await fetch(`${RELAY_URL}${path}`, {
450
+ method: "POST",
451
+ headers,
452
+ body: JSON.stringify(body),
453
+ });
454
+ return res.json();
455
+ }
456
+
457
+ async function relayGet(path) {
458
+ const res = await fetch(`${RELAY_URL}${path}`, {
459
+ method: "GET",
460
+ headers: authHeaders(),
461
+ });
462
+ return res.json();
463
+ }
@@ -0,0 +1,225 @@
1
+ import { callTool, readResource } from "./api.mjs";
2
+ import { doLogin } from "./auth.mjs";
3
+ import { runBridge } from "./codex-bridge.mjs";
4
+ import { loadConfig, getConfig, clearConfig, saveConfig, getConfigPath } from "./config.mjs";
5
+ import { green, red, cyan, dim, bold, die } from "./utils.mjs";
6
+
7
+ const VERSION = "1.0.0";
8
+
9
+ export function run(argv) {
10
+ loadConfig();
11
+ const [command, ...args] = argv;
12
+
13
+ switch (command) {
14
+ case "login": return doLogin();
15
+ case "logout": return cmdLogout();
16
+ case "status": return cmdStatus();
17
+ case "register": return cmdRegister(args);
18
+ case "heartbeat": return cmdHeartbeat(args);
19
+ case "lock": return cmdLock(args);
20
+ case "locks": return cmdLocks(args);
21
+ case "unlock": return cmdUnlock(args);
22
+ case "resources": return callTool("list_resources");
23
+ case "agents": return callTool("list_agents");
24
+ case "tasks": return readResource("botbuddy://tasks");
25
+ case "task": return cmdTask(args);
26
+ case "hours": return cmdHours(args);
27
+ case "browse": return cmdBrowse(args);
28
+ case "codex": return cmdCodex(args);
29
+ case "version": case "--version": case "-v":
30
+ console.log(`botbuddy v${VERSION}`); return;
31
+ case "help": case "--help": case "-h": case undefined:
32
+ return cmdHelp();
33
+ default:
34
+ die(`Unknown command: ${command}. Run ${cyan("botbuddy help")} for usage.`);
35
+ }
36
+ }
37
+
38
+ function cmdHelp() {
39
+ console.log(`${bold("botbuddy")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
40
+
41
+ ${bold("USAGE")}
42
+ botbuddy <command> [options]
43
+
44
+ ${bold("AUTH")}
45
+ login Authenticate via OAuth (PKCE)
46
+ logout Remove saved credentials
47
+ status Show current auth status
48
+
49
+ ${bold("AGENTS")}
50
+ register <name> [type] Register a new agent (type: codex|claude|gpt|custom)
51
+ agents List all agents
52
+ heartbeat [task] Send a heartbeat
53
+
54
+ ${bold("RESOURCES")}
55
+ lock <name> <type> Acquire a single lock (type: port|mcp_server|file|...)
56
+ locks [options] Batch-acquire resources (auto-assigns free ones)
57
+ -p, --port [type] Request a port (frontend|backend, default: frontend)
58
+ -m, --mcp Request an MCP server slot
59
+ -t, --ticket <id> Ticket ID (shared across all locks)
60
+ --pr <id> PR ID (shared across all locks)
61
+ unlock <name> Release a lock
62
+ resources List all resources and locks
63
+
64
+ ${bold("TASKS")}
65
+ task create <title> [-d description] [-p priority]
66
+ task claim <id> Claim a pending task
67
+ task done <id> Mark a task as done
68
+ tasks List all tasks
69
+
70
+ ${bold("SWARM")}
71
+ hours Get current working hours
72
+ hours derive Derive working hours from activity
73
+ hours set <json> Set working hours manually
74
+
75
+ ${bold("BROWSE")}
76
+ browse agents|tasks|resources|activity|settings
77
+
78
+ ${bold("CODEX")}
79
+ codex bridge [options] Start Codex app-server bridge
80
+ -p, --port <port> WebSocket port (default: 4500)
81
+ -r, --repo <path> Repository path (default: cwd)
82
+ -m, --model <model> Default model (default: gpt-5.4)
83
+ --auto-start Auto-start app-server
84
+
85
+ ${bold("OTHER")}
86
+ help Show this help
87
+ version Show version`);
88
+ }
89
+
90
+ function cmdStatus() {
91
+ const cfg = getConfig();
92
+ if (cfg.access_token) {
93
+ console.log(`${green("✓")} Authenticated via OAuth`);
94
+ if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
95
+ if (cfg.client_id) console.log(` Client: ${dim(cfg.client_id)}`);
96
+ if (cfg.token_expires_at) {
97
+ const remaining = cfg.token_expires_at - Date.now();
98
+ if (remaining <= 0) {
99
+ console.log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy login")}`);
100
+ } else {
101
+ const mins = Math.round(remaining / 60000);
102
+ const label = mins > 60 ? `${Math.round(mins / 60)}h ${mins % 60}m` : `${mins}m`;
103
+ console.log(` Token expires in: ${dim(label)}`);
104
+ }
105
+ }
106
+ console.log(` Config: ${dim(getConfigPath())}`);
107
+ } else if (cfg.api_key) {
108
+ console.log(`${green("✓")} Authenticated via API key`);
109
+ if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
110
+ } else {
111
+ console.log(`${red("✗")} Not authenticated`);
112
+ console.log(` Run: ${cyan("botbuddy login")}`);
113
+ }
114
+ }
115
+
116
+ function cmdLogout() {
117
+ clearConfig();
118
+ console.log(`${green("✓")} Logged out. Credentials removed.`);
119
+ }
120
+
121
+ async function cmdRegister(args) {
122
+ const name = args[0];
123
+ if (!name) die("Usage: botbuddy register <name> [type]");
124
+ const type = args[1] || "custom";
125
+ const data = await callTool("register_agent", { name, type });
126
+ const text = data?.result?.content?.[0]?.text;
127
+ if (text) {
128
+ try {
129
+ const parsed = JSON.parse(text);
130
+ if (parsed.api_key) {
131
+ saveConfig({ ...getConfig(), api_key: parsed.api_key, agent_id: parsed.agent_id, agent_name: name });
132
+ }
133
+ } catch {}
134
+ }
135
+ }
136
+
137
+ function cmdHeartbeat(args) {
138
+ return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
139
+ }
140
+
141
+ function cmdLock(args) {
142
+ if (args.length < 2) die("Usage: botbuddy lock <resource_name> <type>");
143
+ return callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] });
144
+ }
145
+
146
+ function cmdLocks(args) {
147
+ const resources = [];
148
+ let ticketId, prId;
149
+ for (let i = 0; i < args.length; i++) {
150
+ if (args[i] === "-p" || args[i] === "--port") {
151
+ const portType = (args[i + 1] && !args[i + 1].startsWith("-")) ? args[++i] : "frontend";
152
+ resources.push({ resource_type: "port", port_type: portType });
153
+ } else if (args[i] === "-m" || args[i] === "--mcp") {
154
+ resources.push({ resource_type: "mcp_server" });
155
+ } else if (args[i] === "-t" || args[i] === "--ticket") {
156
+ ticketId = args[++i];
157
+ } else if (args[i] === "--pr") {
158
+ prId = args[++i];
159
+ }
160
+ }
161
+ if (!resources.length) {
162
+ die("Usage: botbuddy locks -p [frontend|backend] -m [-t ticket] [--pr id]");
163
+ }
164
+ const payload = { resources };
165
+ if (ticketId) payload.ticket_id = ticketId;
166
+ if (prId) payload.pr_id = prId;
167
+ return callTool("acquire_resources", payload);
168
+ }
169
+
170
+ function cmdUnlock(args) {
171
+ if (!args[0]) die("Usage: botbuddy unlock <resource_name>");
172
+ return callTool("release_lock", { resource_name: args[0] });
173
+ }
174
+
175
+ function cmdTask(args) {
176
+ const sub = args[0];
177
+ if (!sub) die("Usage: botbuddy task <create|claim|done> ...");
178
+ switch (sub) {
179
+ case "create": {
180
+ const title = args[1];
181
+ if (!title) die("Usage: botbuddy task create <title> [-d desc] [-p priority]");
182
+ let desc = "", priority = 0;
183
+ for (let i = 2; i < args.length; i++) {
184
+ if (args[i] === "-d" && args[i + 1]) { desc = args[++i]; }
185
+ else if (args[i] === "-p" && args[i + 1]) { priority = parseInt(args[++i], 10); }
186
+ }
187
+ return callTool("create_task", { title, description: desc, priority });
188
+ }
189
+ case "claim":
190
+ if (!args[1]) die("Usage: botbuddy task claim <task_id>");
191
+ return callTool("claim_task", { task_id: args[1] });
192
+ case "done":
193
+ if (!args[1]) die("Usage: botbuddy task done <task_id>");
194
+ return callTool("complete_task", { task_id: args[1] });
195
+ default:
196
+ die(`Unknown task subcommand: ${sub}`);
197
+ }
198
+ }
199
+
200
+ function cmdHours(args) {
201
+ switch (args[0]) {
202
+ case "derive": return callTool("derive_working_hours");
203
+ case "set":
204
+ if (!args[1]) die("Usage: botbuddy hours set '<json_schedule>'");
205
+ return callTool("set_working_hours", { schedule: JSON.parse(args[1]) });
206
+ default:
207
+ return callTool("get_working_hours");
208
+ }
209
+ }
210
+
211
+ function cmdBrowse(args) {
212
+ if (!args[0]) die("Usage: botbuddy browse <agents|tasks|resources|activity|settings>");
213
+ return readResource(`botbuddy://${args[0]}`);
214
+ }
215
+
216
+ function cmdCodex(args) {
217
+ const sub = args[0];
218
+ if (!sub) die("Usage: botbuddy codex <bridge> [options]");
219
+ switch (sub) {
220
+ case "bridge":
221
+ return runBridge(args.slice(1));
222
+ default:
223
+ die(`Unknown codex subcommand: ${sub}. Try: botbuddy codex bridge`);
224
+ }
225
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const CONFIG_DIR = join(homedir(), ".botbuddy");
6
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
7
+
8
+ export const SERVER_URL = "https://tpnivjpoayjmexclfpav.supabase.co/functions/v1/mcp-server";
9
+
10
+ let config = {};
11
+
12
+ export function loadConfig() {
13
+ try {
14
+ if (existsSync(CONFIG_FILE)) {
15
+ config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
16
+ }
17
+ } catch {
18
+ config = {};
19
+ }
20
+ return config;
21
+ }
22
+
23
+ export function saveConfig(data) {
24
+ mkdirSync(CONFIG_DIR, { recursive: true });
25
+ writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
26
+ chmodSync(CONFIG_FILE, 0o600);
27
+ config = data;
28
+ }
29
+
30
+ export function getConfig() {
31
+ return config;
32
+ }
33
+
34
+ export function clearConfig() {
35
+ try { unlinkSync(CONFIG_FILE); } catch {}
36
+ }
37
+
38
+ export function getConfigPath() {
39
+ return CONFIG_FILE;
40
+ }
package/src/utils.mjs ADDED
@@ -0,0 +1,19 @@
1
+ export const red = (s) => `\x1b[31m${s}\x1b[0m`;
2
+ export const green = (s) => `\x1b[32m${s}\x1b[0m`;
3
+ export const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
4
+ export const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
5
+ export const dim = (s) => `\x1b[2m${s}\x1b[0m`;
6
+ export const bold = (s) => `\x1b[1m${s}\x1b[0m`;
7
+
8
+ export function die(msg) {
9
+ console.error(`${red("✗")} ${msg}`);
10
+ process.exit(1);
11
+ }
12
+
13
+ export function prettyJson(text) {
14
+ try {
15
+ return JSON.stringify(JSON.parse(text), null, 2);
16
+ } catch {
17
+ return text;
18
+ }
19
+ }