@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.
- package/bin/botbuddy.mjs +3 -0
- package/package.json +21 -0
- package/src/api.mjs +61 -0
- package/src/auth.mjs +72 -0
- package/src/codex-bridge.mjs +463 -0
- package/src/commands.mjs +225 -0
- package/src/config.mjs +40 -0
- package/src/utils.mjs +19 -0
package/bin/botbuddy.mjs
ADDED
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
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -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
|
+
}
|