@supercollab/cli 0.4.3 → 0.4.5
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/README.md +43 -5
- package/bin/supercollab.js +897 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,11 +15,33 @@ npm install -g @supercollab/cli
|
|
|
15
15
|
supercollab
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Running `supercollab` opens
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
Running `supercollab` opens guided setup the first time, then opens the full
|
|
19
|
+
interactive menu after your account and local agent are configured. You can also
|
|
20
|
+
open the menu directly:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
supercollab menu
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The menu is designed so normal use does not require memorizing command-line
|
|
27
|
+
flags. It includes scrollable/selectable flows for:
|
|
28
|
+
|
|
29
|
+
- creating a room
|
|
30
|
+
- joining with a private invite
|
|
31
|
+
- choosing an existing room
|
|
32
|
+
- typing a room ID manually when needed
|
|
33
|
+
- activating a room for a local project directory
|
|
34
|
+
- creating private invites
|
|
35
|
+
- sending, reading, syncing, and searching room messages
|
|
36
|
+
- running the local system check and BGE model install/warmup
|
|
37
|
+
- viewing MCP config snippets
|
|
38
|
+
- managing sessions and server settings
|
|
39
|
+
|
|
40
|
+
Guided setup detects your OS/CPU/Node runtime, verifies native SQLite and
|
|
41
|
+
sqlite-vec, downloads and warms the BGE model locally, writes the selected local
|
|
42
|
+
engine into `~/.supercollab/config.json`, creates or logs into your account,
|
|
43
|
+
registers the local agent, creates/joins/selects a room, activates a project
|
|
44
|
+
directory, and prints MCP config.
|
|
23
45
|
|
|
24
46
|
You can also run the checks directly:
|
|
25
47
|
|
|
@@ -111,6 +133,22 @@ Print MCP config:
|
|
|
111
133
|
supercollab mcp print-config --client codex
|
|
112
134
|
```
|
|
113
135
|
|
|
136
|
+
For Claude Desktop on macOS, generate the config from the project directory you
|
|
137
|
+
want the agent to use:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
cd /path/to/project
|
|
141
|
+
supercollab mcp print-config --client claude
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The Claude config uses absolute Node and CLI paths plus an explicit `PATH` so it
|
|
145
|
+
does not depend on Homebrew, nvm, zsh, or GUI app shell startup behavior. Before
|
|
146
|
+
opening Claude, verify the local MCP handshake:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
supercollab mcp smoke
|
|
150
|
+
```
|
|
151
|
+
|
|
114
152
|
Default server: `https://hyper.polynode.dev`.
|
|
115
153
|
|
|
116
154
|
Local config is stored at `~/.supercollab/config.json` with mode `0600`; the
|
package/bin/supercollab.js
CHANGED
|
@@ -3,11 +3,13 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import * as readlineCore from 'node:readline';
|
|
8
9
|
import { stdin as input, stdout as output } from 'node:process';
|
|
9
10
|
|
|
10
|
-
const VERSION = '0.4.
|
|
11
|
+
const VERSION = '0.4.5';
|
|
12
|
+
const CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
11
13
|
const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
|
|
12
14
|
const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
|
|
13
15
|
const SESSION_TTL_SKEW = 60;
|
|
@@ -34,6 +36,7 @@ function printHelp() {
|
|
|
34
36
|
console.log(`SuperCollab CLI ${VERSION}
|
|
35
37
|
|
|
36
38
|
Usage:
|
|
39
|
+
supercollab menu
|
|
37
40
|
supercollab setup
|
|
38
41
|
supercollab doctor [--json] [--skip-model]
|
|
39
42
|
supercollab register --username NAME [--password PASS] [--label LABEL]
|
|
@@ -59,6 +62,7 @@ Usage:
|
|
|
59
62
|
supercollab embeddings warmup
|
|
60
63
|
supercollab mcp stdio
|
|
61
64
|
supercollab mcp print-config --client codex
|
|
65
|
+
supercollab mcp smoke [--timeout 5000]
|
|
62
66
|
supercollab config path
|
|
63
67
|
|
|
64
68
|
Options:
|
|
@@ -1168,9 +1172,96 @@ async function runMcp(opts) {
|
|
|
1168
1172
|
}
|
|
1169
1173
|
}
|
|
1170
1174
|
|
|
1175
|
+
function encodeMcpMessage(msg) {
|
|
1176
|
+
const raw = Buffer.from(JSON.stringify(msg));
|
|
1177
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${raw.length}\r\n\r\n`), raw]);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function parseMcpMessages(state) {
|
|
1181
|
+
const messages = [];
|
|
1182
|
+
while (true) {
|
|
1183
|
+
const headerEnd = state.buffer.indexOf('\r\n\r\n');
|
|
1184
|
+
if (headerEnd < 0) break;
|
|
1185
|
+
const header = state.buffer.subarray(0, headerEnd).toString();
|
|
1186
|
+
const match = header.match(/content-length:\s*(\d+)/i);
|
|
1187
|
+
if (!match) {
|
|
1188
|
+
state.parseError = `missing content-length in MCP output: ${header.slice(0, 200)}`;
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
const length = Number(match[1]);
|
|
1192
|
+
const total = headerEnd + 4 + length;
|
|
1193
|
+
if (state.buffer.length < total) break;
|
|
1194
|
+
const body = state.buffer.subarray(headerEnd + 4, total).toString();
|
|
1195
|
+
state.buffer = state.buffer.subarray(total);
|
|
1196
|
+
try {
|
|
1197
|
+
messages.push(JSON.parse(body));
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
state.parseError = err.message || String(err);
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return messages;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function runMcpSmoke(opts) {
|
|
1207
|
+
const file = configPath(opts);
|
|
1208
|
+
const timeoutMs = Math.max(1000, Math.min(Number(opts.timeout || 5000), 30000));
|
|
1209
|
+
const args = [CLI_ENTRY, 'mcp', 'stdio', '--config', file];
|
|
1210
|
+
return await new Promise((resolve, reject) => {
|
|
1211
|
+
const child = spawn(process.execPath, args, {
|
|
1212
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1213
|
+
env: {
|
|
1214
|
+
...process.env,
|
|
1215
|
+
PATH: defaultPathEnv(),
|
|
1216
|
+
SUPERCOLLAB_CONFIG: file,
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
const state = { buffer: Buffer.alloc(0), parseError: null };
|
|
1220
|
+
const responses = [];
|
|
1221
|
+
let stderr = '';
|
|
1222
|
+
let settled = false;
|
|
1223
|
+
const finish = (err, result) => {
|
|
1224
|
+
if (settled) return;
|
|
1225
|
+
settled = true;
|
|
1226
|
+
clearTimeout(timer);
|
|
1227
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
1228
|
+
if (err) reject(err);
|
|
1229
|
+
else resolve(result);
|
|
1230
|
+
};
|
|
1231
|
+
const timer = setTimeout(() => {
|
|
1232
|
+
finish(new Error(`MCP smoke timed out after ${timeoutMs}ms. stderr: ${stderr.slice(0, 1000)}`));
|
|
1233
|
+
}, timeoutMs);
|
|
1234
|
+
child.on('error', (err) => finish(err));
|
|
1235
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
1236
|
+
child.stdout.on('data', (chunk) => {
|
|
1237
|
+
state.buffer = Buffer.concat([state.buffer, chunk]);
|
|
1238
|
+
for (const msg of parseMcpMessages(state)) responses.push(msg);
|
|
1239
|
+
if (state.parseError) {
|
|
1240
|
+
finish(new Error(state.parseError));
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (responses.some((msg) => msg.id === 1) && responses.some((msg) => msg.id === 2)) {
|
|
1244
|
+
const init = responses.find((msg) => msg.id === 1);
|
|
1245
|
+
const tools = responses.find((msg) => msg.id === 2);
|
|
1246
|
+
finish(null, {
|
|
1247
|
+
ok: Boolean(init?.result?.serverInfo && Array.isArray(tools?.result?.tools)),
|
|
1248
|
+
command: process.execPath,
|
|
1249
|
+
args,
|
|
1250
|
+
config: file,
|
|
1251
|
+
server_info: init?.result?.serverInfo || null,
|
|
1252
|
+
tools: (tools?.result?.tools || []).map((tool) => tool.name),
|
|
1253
|
+
stderr: stderr.trim() || null,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
child.stdin.write(encodeMcpMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }));
|
|
1258
|
+
child.stdin.write(encodeMcpMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }));
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1171
1262
|
function printCodexConfig(opts) {
|
|
1172
1263
|
const file = configPath(opts);
|
|
1173
|
-
console.log(mcpConfigText(String(opts.client || 'codex'), file));
|
|
1264
|
+
console.log(mcpConfigText(String(opts.client || 'codex'), file, opts));
|
|
1174
1265
|
}
|
|
1175
1266
|
|
|
1176
1267
|
async function embeddingStatus() {
|
|
@@ -1302,6 +1393,234 @@ function promptRequired(value) {
|
|
|
1302
1393
|
return String(value || '').trim() ? undefined : 'Required';
|
|
1303
1394
|
}
|
|
1304
1395
|
|
|
1396
|
+
function trimmed(value) {
|
|
1397
|
+
return String(value || '').trim();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function shorten(value, max = 80) {
|
|
1401
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
1402
|
+
if (text.length <= max) return text;
|
|
1403
|
+
return `${text.slice(0, Math.max(0, max - 3))}...`;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function roomIdFrom(room) {
|
|
1407
|
+
return trimmed(room?.room_id || room?.id || room?.workspace_id);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function roomTitleFrom(room) {
|
|
1411
|
+
return trimmed(room?.title || room?.name || room?.slug || room?.goal || roomIdFrom(room));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function roomsFromResponse(data) {
|
|
1415
|
+
if (Array.isArray(data)) return data;
|
|
1416
|
+
if (Array.isArray(data?.rooms)) return data.rooms;
|
|
1417
|
+
if (Array.isArray(data?.workspaces)) return data.workspaces;
|
|
1418
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
1419
|
+
return [];
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function hasLocalRoomKey(config, roomId) {
|
|
1423
|
+
return Boolean(config.roomKeys?.[roomId]);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function roomLabel(room, config) {
|
|
1427
|
+
const roomId = roomIdFrom(room);
|
|
1428
|
+
const title = roomTitleFrom(room);
|
|
1429
|
+
const prefix = title && title !== roomId ? `${shorten(title, 42)} ` : '';
|
|
1430
|
+
const key = hasLocalRoomKey(config, roomId) ? 'key saved' : 'key missing';
|
|
1431
|
+
return `${prefix}(${roomId})`;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function roomHint(room, config) {
|
|
1435
|
+
const roomId = roomIdFrom(room);
|
|
1436
|
+
const pieces = [];
|
|
1437
|
+
const goal = trimmed(room?.goal || room?.description);
|
|
1438
|
+
if (goal) pieces.push(shorten(goal, 48));
|
|
1439
|
+
pieces.push(hasLocalRoomKey(config, roomId) ? 'encrypted local search ready' : 'join/import key to decrypt');
|
|
1440
|
+
return pieces.join(' - ');
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function listRoomsForMenu(config) {
|
|
1444
|
+
const data = await apiAsAgent(config, 'GET', '/v1/rooms');
|
|
1445
|
+
return roomsFromResponse(data).filter((room) => roomIdFrom(room));
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
async function promptManualRoomId(prompts, message = 'Room ID') {
|
|
1449
|
+
const roomId = await prompts.text({
|
|
1450
|
+
message,
|
|
1451
|
+
placeholder: 'room_...',
|
|
1452
|
+
validate: promptRequired,
|
|
1453
|
+
});
|
|
1454
|
+
if (prompts.isCancel(roomId)) throw new Error('cancelled');
|
|
1455
|
+
return trimmed(roomId);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async function ensureRoomKeyFromMenu(config, file, prompts, roomId) {
|
|
1459
|
+
try {
|
|
1460
|
+
ensureRoomKey(config, roomId);
|
|
1461
|
+
return true;
|
|
1462
|
+
} catch {
|
|
1463
|
+
const choice = await prompts.select({
|
|
1464
|
+
message: `No local room key for ${roomId}`,
|
|
1465
|
+
options: [
|
|
1466
|
+
{ value: 'invite', label: 'Join with private invite', hint: 'recommended if another member invited you' },
|
|
1467
|
+
{ value: 'key', label: 'Paste room key', hint: 'sck_... from a trusted device' },
|
|
1468
|
+
{ value: 'choose', label: 'Choose another room' },
|
|
1469
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
1470
|
+
],
|
|
1471
|
+
});
|
|
1472
|
+
if (prompts.isCancel(choice) || choice === 'cancel') throw new Error('cancelled');
|
|
1473
|
+
if (choice === 'choose') return false;
|
|
1474
|
+
if (choice === 'invite') {
|
|
1475
|
+
const joined = await promptJoinRoom(config, file, prompts, { quiet: true });
|
|
1476
|
+
return Boolean(joined?.room_id === roomId || joined?.workspace_id === roomId || hasLocalRoomKey(config, roomId));
|
|
1477
|
+
}
|
|
1478
|
+
const key = await prompts.text({
|
|
1479
|
+
message: 'Paste room key',
|
|
1480
|
+
placeholder: 'sck_...',
|
|
1481
|
+
validate: (value) => {
|
|
1482
|
+
try {
|
|
1483
|
+
roomKeyBytes(trimmed(value));
|
|
1484
|
+
return undefined;
|
|
1485
|
+
} catch {
|
|
1486
|
+
return 'Invalid room key';
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
if (prompts.isCancel(key)) throw new Error('cancelled');
|
|
1491
|
+
storeRoomKey(config, roomId, trimmed(key));
|
|
1492
|
+
saveConfig(config, file);
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function selectRoom(config, file, prompts, options = {}) {
|
|
1498
|
+
const {
|
|
1499
|
+
message = 'Choose room',
|
|
1500
|
+
requireKey = true,
|
|
1501
|
+
includeCreate = false,
|
|
1502
|
+
includeJoin = false,
|
|
1503
|
+
includeManual = true,
|
|
1504
|
+
} = options;
|
|
1505
|
+
|
|
1506
|
+
while (true) {
|
|
1507
|
+
let rooms = [];
|
|
1508
|
+
try {
|
|
1509
|
+
rooms = await listRoomsForMenu(config);
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
prompts.note(err.message || String(err), 'Could not load rooms');
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const choices = rooms.map((room) => ({
|
|
1515
|
+
value: roomIdFrom(room),
|
|
1516
|
+
label: roomLabel(room, config),
|
|
1517
|
+
hint: roomHint(room, config),
|
|
1518
|
+
}));
|
|
1519
|
+
if (includeCreate) choices.push({ value: '__create', label: 'Create new room', hint: 'set title and goal now' });
|
|
1520
|
+
if (includeJoin) choices.push({ value: '__join', label: 'Join with private invite', hint: 'paste sci_...sck_...' });
|
|
1521
|
+
if (includeManual) choices.push({ value: '__manual', label: 'Type room ID manually', hint: 'room_...' });
|
|
1522
|
+
choices.push({ value: '__back', label: 'Back' });
|
|
1523
|
+
|
|
1524
|
+
const selected = await prompts.select({ message, options: choices });
|
|
1525
|
+
if (prompts.isCancel(selected) || selected === '__back') throw new Error('cancelled');
|
|
1526
|
+
|
|
1527
|
+
if (selected === '__create') {
|
|
1528
|
+
const created = await promptCreateRoom(config, file, prompts, { quiet: true });
|
|
1529
|
+
if (created?.room_id) return created.room_id;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
if (selected === '__join') {
|
|
1533
|
+
const joined = await promptJoinRoom(config, file, prompts, { quiet: true });
|
|
1534
|
+
const joinedRoomId = joined?.room_id || joined?.workspace_id;
|
|
1535
|
+
if (joinedRoomId) return joinedRoomId;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const roomId = selected === '__manual'
|
|
1540
|
+
? await promptManualRoomId(prompts)
|
|
1541
|
+
: String(selected);
|
|
1542
|
+
|
|
1543
|
+
if (!requireKey) return roomId;
|
|
1544
|
+
const ok = await ensureRoomKeyFromMenu(config, file, prompts, roomId);
|
|
1545
|
+
if (ok) return roomId;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function formatMessagesForNote(messages, maxRows = 12) {
|
|
1550
|
+
const rows = (messages || []).slice(-maxRows);
|
|
1551
|
+
if (!rows.length) return 'No messages yet.';
|
|
1552
|
+
return rows.map((row) => {
|
|
1553
|
+
const when = trimmed(row.created_at).replace('T', ' ').replace('Z', '');
|
|
1554
|
+
return `[${when}] ${row.sender_label || row.actor_id || 'agent'}\n${shorten(row.body, 260)}`;
|
|
1555
|
+
}).join('\n\n');
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function formatSearchForNote(results, maxRows = 8) {
|
|
1559
|
+
const rows = (results || []).slice(0, maxRows);
|
|
1560
|
+
if (!rows.length) return 'No matching messages.';
|
|
1561
|
+
return rows.map((row, idx) => {
|
|
1562
|
+
const sources = Array.isArray(row.search_sources) ? row.search_sources.join('+') : 'match';
|
|
1563
|
+
return `${idx + 1}. ${row.sender_label || row.actor_id || 'agent'} - ${sources}\n${shorten(row.body, 260)}`;
|
|
1564
|
+
}).join('\n\n');
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function promptCreateRoom(config, file, prompts, options = {}) {
|
|
1568
|
+
const title = await prompts.text({
|
|
1569
|
+
message: 'Room title',
|
|
1570
|
+
placeholder: 'Launch Room',
|
|
1571
|
+
validate: promptRequired,
|
|
1572
|
+
});
|
|
1573
|
+
if (prompts.isCancel(title)) throw new Error('cancelled');
|
|
1574
|
+
const goal = await prompts.text({
|
|
1575
|
+
message: 'Room goal',
|
|
1576
|
+
placeholder: 'Coordinate agents on this project',
|
|
1577
|
+
validate: promptRequired,
|
|
1578
|
+
});
|
|
1579
|
+
if (prompts.isCancel(goal)) throw new Error('cancelled');
|
|
1580
|
+
const slug = await prompts.text({
|
|
1581
|
+
message: 'Optional short slug',
|
|
1582
|
+
placeholder: 'press Enter to skip',
|
|
1583
|
+
});
|
|
1584
|
+
if (prompts.isCancel(slug)) throw new Error('cancelled');
|
|
1585
|
+
const spin = prompts.spinner();
|
|
1586
|
+
spin.start('Creating encrypted room');
|
|
1587
|
+
try {
|
|
1588
|
+
const created = await doRoomCreate(config, file, {
|
|
1589
|
+
title: trimmed(title),
|
|
1590
|
+
goal: trimmed(goal),
|
|
1591
|
+
slug: trimmed(slug) || undefined,
|
|
1592
|
+
});
|
|
1593
|
+
const roomId = created.room_id || created.id;
|
|
1594
|
+
spin.stop(`Room created: ${roomId}`);
|
|
1595
|
+
if (!options.quiet) prompts.note(`Room ID: ${roomId}\nLocal room key: saved on this machine`, 'Room created');
|
|
1596
|
+
return { ...created, room_id: roomId };
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
spin.stop('Room creation failed');
|
|
1599
|
+
throw err;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function promptJoinRoom(config, file, prompts, options = {}) {
|
|
1604
|
+
const invite = await prompts.text({
|
|
1605
|
+
message: 'Paste private invite',
|
|
1606
|
+
placeholder: 'sci_....sck_...',
|
|
1607
|
+
validate: promptRequired,
|
|
1608
|
+
});
|
|
1609
|
+
if (prompts.isCancel(invite)) throw new Error('cancelled');
|
|
1610
|
+
const spin = prompts.spinner();
|
|
1611
|
+
spin.start('Joining room and saving local room key');
|
|
1612
|
+
try {
|
|
1613
|
+
const joined = await doRoomJoin(config, file, { invite: trimmed(invite) });
|
|
1614
|
+
const roomId = joined.room_id || joined.workspace_id;
|
|
1615
|
+
spin.stop(`Joined room: ${roomId}`);
|
|
1616
|
+
if (!options.quiet) prompts.note(`Room ID: ${roomId}\nLocal room key saved: ${joined.room_key_saved ? 'yes' : 'no'}`, 'Joined');
|
|
1617
|
+
return { ...joined, room_id: roomId };
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
spin.stop('Join failed');
|
|
1620
|
+
throw err;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1305
1624
|
async function ensureAgentForSetup(config, file, prompts) {
|
|
1306
1625
|
if (config.agentId && config.agentPrivateKeyPem) return null;
|
|
1307
1626
|
const label = await prompts.text({
|
|
@@ -1360,6 +1679,7 @@ async function runRoomSetup(config, file, prompts) {
|
|
|
1360
1679
|
const choice = await prompts.select({
|
|
1361
1680
|
message: 'Room setup',
|
|
1362
1681
|
options: [
|
|
1682
|
+
{ value: 'existing', label: 'Use an existing room', hint: 'pick from your rooms or type a room ID' },
|
|
1363
1683
|
{ value: 'create', label: 'Create a new room', hint: 'start solo or invite agents later' },
|
|
1364
1684
|
{ value: 'join', label: 'Join with private invite', hint: 'paste sci_...sck_...' },
|
|
1365
1685
|
{ value: 'skip', label: 'Skip for now', hint: 'set up auth and local engine only' },
|
|
@@ -1368,35 +1688,40 @@ async function runRoomSetup(config, file, prompts) {
|
|
|
1368
1688
|
if (prompts.isCancel(choice)) throw new Error('cancelled');
|
|
1369
1689
|
if (choice === 'skip') return null;
|
|
1370
1690
|
|
|
1371
|
-
if (choice === '
|
|
1372
|
-
const
|
|
1373
|
-
message: '
|
|
1374
|
-
|
|
1375
|
-
|
|
1691
|
+
if (choice === 'existing') {
|
|
1692
|
+
const roomId = await selectRoom(config, file, prompts, {
|
|
1693
|
+
message: 'Choose room to use',
|
|
1694
|
+
requireKey: true,
|
|
1695
|
+
includeCreate: true,
|
|
1696
|
+
includeJoin: true,
|
|
1376
1697
|
});
|
|
1377
|
-
|
|
1378
|
-
|
|
1698
|
+
return { room_id: roomId, existing: true };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
if (choice === 'join') {
|
|
1702
|
+
const joined = await promptJoinRoom(config, file, prompts, { quiet: true });
|
|
1379
1703
|
return { room_id: joined.room_id || joined.workspace_id, joined };
|
|
1380
1704
|
}
|
|
1381
1705
|
|
|
1382
|
-
const
|
|
1383
|
-
message: 'Room title',
|
|
1384
|
-
placeholder: 'Launch Room',
|
|
1385
|
-
validate: promptRequired,
|
|
1386
|
-
});
|
|
1387
|
-
if (prompts.isCancel(title)) throw new Error('cancelled');
|
|
1388
|
-
const goal = await prompts.text({
|
|
1389
|
-
message: 'Room goal',
|
|
1390
|
-
placeholder: 'Coordinate agents on this project',
|
|
1391
|
-
validate: promptRequired,
|
|
1392
|
-
});
|
|
1393
|
-
if (prompts.isCancel(goal)) throw new Error('cancelled');
|
|
1394
|
-
const created = await doRoomCreate(config, file, { title: String(title), goal: String(goal) });
|
|
1706
|
+
const created = await promptCreateRoom(config, file, prompts, { quiet: true });
|
|
1395
1707
|
return { room_id: created.room_id || created.id, created };
|
|
1396
1708
|
}
|
|
1397
1709
|
|
|
1398
1710
|
async function runActivationSetup(config, file, roomId, prompts) {
|
|
1399
|
-
if (!roomId)
|
|
1711
|
+
if (!roomId) {
|
|
1712
|
+
const pick = await prompts.confirm({
|
|
1713
|
+
message: 'Activate an existing room for this project directory?',
|
|
1714
|
+
initialValue: false,
|
|
1715
|
+
});
|
|
1716
|
+
if (prompts.isCancel(pick)) throw new Error('cancelled');
|
|
1717
|
+
if (!pick) return null;
|
|
1718
|
+
roomId = await selectRoom(config, file, prompts, {
|
|
1719
|
+
message: 'Choose room to activate',
|
|
1720
|
+
requireKey: true,
|
|
1721
|
+
includeCreate: true,
|
|
1722
|
+
includeJoin: true,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1400
1725
|
const shouldActivate = await prompts.confirm({
|
|
1401
1726
|
message: 'Activate SuperCollab for a local project directory now?',
|
|
1402
1727
|
initialValue: true,
|
|
@@ -1435,14 +1760,35 @@ async function runSetupSmoke(config, file, roomId, prompts) {
|
|
|
1435
1760
|
};
|
|
1436
1761
|
}
|
|
1437
1762
|
|
|
1438
|
-
function
|
|
1763
|
+
function defaultPathEnv() {
|
|
1764
|
+
const parts = [
|
|
1765
|
+
path.dirname(process.execPath),
|
|
1766
|
+
'/opt/homebrew/bin',
|
|
1767
|
+
'/usr/local/bin',
|
|
1768
|
+
'/usr/bin',
|
|
1769
|
+
'/bin',
|
|
1770
|
+
'/usr/sbin',
|
|
1771
|
+
'/sbin',
|
|
1772
|
+
process.env.PATH || '',
|
|
1773
|
+
].flatMap((part) => String(part || '').split(path.delimiter)).filter(Boolean);
|
|
1774
|
+
return Array.from(new Set(parts)).join(path.delimiter);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function mcpConfigText(client, file, opts = {}) {
|
|
1778
|
+
const absoluteFile = path.resolve(file);
|
|
1439
1779
|
const escaped = file.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
1440
1780
|
if (client === 'claude') {
|
|
1781
|
+
const cwd = path.resolve(String(opts.cwd || process.env.SUPERCOLLAB_WORKDIR || process.cwd()));
|
|
1441
1782
|
return JSON.stringify({
|
|
1442
1783
|
mcpServers: {
|
|
1443
1784
|
supercollab: {
|
|
1444
|
-
|
|
1445
|
-
|
|
1785
|
+
type: 'stdio',
|
|
1786
|
+
command: process.execPath,
|
|
1787
|
+
args: [CLI_ENTRY, 'mcp', 'stdio', '--config', absoluteFile],
|
|
1788
|
+
env: {
|
|
1789
|
+
PATH: defaultPathEnv(),
|
|
1790
|
+
SUPERCOLLAB_WORKDIR: cwd,
|
|
1791
|
+
},
|
|
1446
1792
|
},
|
|
1447
1793
|
},
|
|
1448
1794
|
}, null, 2);
|
|
@@ -1453,6 +1799,523 @@ function mcpConfigText(client, file) {
|
|
|
1453
1799
|
return `supercollab mcp stdio --config "${escaped}"`;
|
|
1454
1800
|
}
|
|
1455
1801
|
|
|
1802
|
+
async function promptSystemCheck(config, file, prompts) {
|
|
1803
|
+
const spin = prompts.spinner();
|
|
1804
|
+
spin.start('Checking this machine and warming the local BGE model');
|
|
1805
|
+
const doctor = await runDoctor(config, file, {});
|
|
1806
|
+
spin.stop(doctor.ok ? 'Local engine ready' : 'Local engine needs attention');
|
|
1807
|
+
const checks = [
|
|
1808
|
+
`Node: ${doctor.checks.node_supported.ok ? 'ok' : 'needs Node >=20'} (${doctor.system.node})`,
|
|
1809
|
+
`SQLite vector engine: ${doctor.checks.native_sqlite_vec.ok ? 'ok' : 'failed'}${doctor.checks.native_sqlite_vec.sqlite_vec_version ? ` (${doctor.checks.native_sqlite_vec.sqlite_vec_version})` : ''}`,
|
|
1810
|
+
`BGE model: ${doctor.checks.bge_model.ok ? 'ok' : doctor.checks.bge_model.skipped ? 'skipped' : 'failed'}`,
|
|
1811
|
+
`Embedding profile: ${doctor.local_engine.embedding_profile_id}`,
|
|
1812
|
+
];
|
|
1813
|
+
if (doctor.advice?.length) checks.push('', ...doctor.advice);
|
|
1814
|
+
prompts.note(checks.join('\n'), doctor.ok ? 'Doctor passed' : 'Doctor');
|
|
1815
|
+
return doctor;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
async function promptActivateRoom(config, file, prompts, options = {}) {
|
|
1819
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
1820
|
+
message: 'Choose room to activate',
|
|
1821
|
+
requireKey: true,
|
|
1822
|
+
includeCreate: true,
|
|
1823
|
+
includeJoin: true,
|
|
1824
|
+
});
|
|
1825
|
+
const cwd = await prompts.text({
|
|
1826
|
+
message: 'Project directory',
|
|
1827
|
+
defaultValue: options.cwd || process.cwd(),
|
|
1828
|
+
placeholder: options.cwd || process.cwd(),
|
|
1829
|
+
validate: (value) => {
|
|
1830
|
+
const resolved = path.resolve(String(value || ''));
|
|
1831
|
+
return fs.existsSync(resolved) ? undefined : 'Directory does not exist';
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
if (prompts.isCancel(cwd)) throw new Error('cancelled');
|
|
1835
|
+
const activation = activate(config, file, { room: roomId, cwd: String(cwd) });
|
|
1836
|
+
prompts.note(`${activation.instructions}\n\nActivation root: ${activation.cwd}`, 'Workspace active');
|
|
1837
|
+
return activation;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function promptDeactivateRoom(config, file, prompts, options = {}) {
|
|
1841
|
+
const cwd = await prompts.text({
|
|
1842
|
+
message: 'Directory to deactivate',
|
|
1843
|
+
defaultValue: options.cwd || process.cwd(),
|
|
1844
|
+
placeholder: options.cwd || process.cwd(),
|
|
1845
|
+
validate: (value) => fs.existsSync(path.resolve(String(value || ''))) ? undefined : 'Directory does not exist',
|
|
1846
|
+
});
|
|
1847
|
+
if (prompts.isCancel(cwd)) throw new Error('cancelled');
|
|
1848
|
+
const result = deactivate(config, file, { cwd: String(cwd) });
|
|
1849
|
+
prompts.note(`SuperCollab is off for ${result.cwd}`, 'Workspace deactivated');
|
|
1850
|
+
return result;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
async function promptCreateInvite(config, file, prompts, options = {}) {
|
|
1854
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
1855
|
+
message: 'Choose room to invite into',
|
|
1856
|
+
requireKey: true,
|
|
1857
|
+
includeCreate: true,
|
|
1858
|
+
includeJoin: false,
|
|
1859
|
+
});
|
|
1860
|
+
const role = await prompts.select({
|
|
1861
|
+
message: 'Invite role',
|
|
1862
|
+
options: [
|
|
1863
|
+
{ value: 'member', label: 'Member', hint: 'normal agent/user access' },
|
|
1864
|
+
],
|
|
1865
|
+
});
|
|
1866
|
+
if (prompts.isCancel(role)) throw new Error('cancelled');
|
|
1867
|
+
const ttl = await prompts.select({
|
|
1868
|
+
message: 'Invite expiry',
|
|
1869
|
+
options: [
|
|
1870
|
+
{ value: 86400, label: '24 hours' },
|
|
1871
|
+
{ value: 3600, label: '1 hour' },
|
|
1872
|
+
{ value: 604800, label: '7 days' },
|
|
1873
|
+
],
|
|
1874
|
+
});
|
|
1875
|
+
if (prompts.isCancel(ttl)) throw new Error('cancelled');
|
|
1876
|
+
const spin = prompts.spinner();
|
|
1877
|
+
spin.start('Creating private invite');
|
|
1878
|
+
try {
|
|
1879
|
+
const data = await doRoomInvite(config, { room: roomId, role, ttl_seconds: ttl });
|
|
1880
|
+
spin.stop('Private invite created');
|
|
1881
|
+
prompts.note(data.private_invite, 'Share this private invite');
|
|
1882
|
+
return data;
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
spin.stop('Invite failed');
|
|
1885
|
+
throw err;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
async function promptSendMessage(config, file, prompts, options = {}) {
|
|
1890
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
1891
|
+
message: 'Choose room to message',
|
|
1892
|
+
requireKey: true,
|
|
1893
|
+
includeCreate: true,
|
|
1894
|
+
includeJoin: true,
|
|
1895
|
+
});
|
|
1896
|
+
let channel = await prompts.select({
|
|
1897
|
+
message: 'Channel',
|
|
1898
|
+
options: [
|
|
1899
|
+
{ value: 'agents', label: 'Agents', hint: 'default coordination chat' },
|
|
1900
|
+
{ value: 'progress', label: 'Progress', hint: 'status notes' },
|
|
1901
|
+
{ value: 'decisions', label: 'Decisions', hint: 'architecture/product decisions' },
|
|
1902
|
+
{ value: 'blockers', label: 'Blockers', hint: 'things that need attention' },
|
|
1903
|
+
{ value: '__custom', label: 'Type custom channel' },
|
|
1904
|
+
],
|
|
1905
|
+
});
|
|
1906
|
+
if (prompts.isCancel(channel)) throw new Error('cancelled');
|
|
1907
|
+
if (channel === '__custom') {
|
|
1908
|
+
channel = await prompts.text({ message: 'Channel name', validate: promptRequired });
|
|
1909
|
+
if (prompts.isCancel(channel)) throw new Error('cancelled');
|
|
1910
|
+
}
|
|
1911
|
+
const kind = await prompts.select({
|
|
1912
|
+
message: 'Message type',
|
|
1913
|
+
options: [
|
|
1914
|
+
{ value: 'chat.message', label: 'Chat message' },
|
|
1915
|
+
{ value: 'progress.note', label: 'Progress note' },
|
|
1916
|
+
{ value: 'decision.note', label: 'Decision note' },
|
|
1917
|
+
{ value: 'blocker.note', label: 'Blocker note' },
|
|
1918
|
+
],
|
|
1919
|
+
});
|
|
1920
|
+
if (prompts.isCancel(kind)) throw new Error('cancelled');
|
|
1921
|
+
const text = await prompts.text({
|
|
1922
|
+
message: 'Message',
|
|
1923
|
+
placeholder: 'Concise agent-to-agent note',
|
|
1924
|
+
validate: promptRequired,
|
|
1925
|
+
});
|
|
1926
|
+
if (prompts.isCancel(text)) throw new Error('cancelled');
|
|
1927
|
+
const spin = prompts.spinner();
|
|
1928
|
+
spin.start('Encrypting, uploading, and indexing locally');
|
|
1929
|
+
try {
|
|
1930
|
+
const data = await doChatSend(config, file, {
|
|
1931
|
+
room: roomId,
|
|
1932
|
+
text: String(text),
|
|
1933
|
+
channel: trimmed(channel) || 'agents',
|
|
1934
|
+
kind,
|
|
1935
|
+
});
|
|
1936
|
+
spin.stop('Message sent');
|
|
1937
|
+
prompts.note(`Room: ${roomId}\nMessage ID: ${data.message?.message_id || data.message?.id || 'saved'}`, 'Sent');
|
|
1938
|
+
return data;
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
spin.stop('Send failed');
|
|
1941
|
+
throw err;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function promptReadMessages(config, file, prompts, options = {}) {
|
|
1946
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
1947
|
+
message: 'Choose room to read',
|
|
1948
|
+
requireKey: true,
|
|
1949
|
+
includeCreate: false,
|
|
1950
|
+
includeJoin: true,
|
|
1951
|
+
});
|
|
1952
|
+
const limit = await prompts.select({
|
|
1953
|
+
message: 'How many recent messages?',
|
|
1954
|
+
options: [
|
|
1955
|
+
{ value: 20, label: '20 messages' },
|
|
1956
|
+
{ value: 50, label: '50 messages' },
|
|
1957
|
+
{ value: 100, label: '100 messages' },
|
|
1958
|
+
{ value: 200, label: '200 messages' },
|
|
1959
|
+
],
|
|
1960
|
+
});
|
|
1961
|
+
if (prompts.isCancel(limit)) throw new Error('cancelled');
|
|
1962
|
+
const spin = prompts.spinner();
|
|
1963
|
+
spin.start('Syncing and decrypting transcript locally');
|
|
1964
|
+
try {
|
|
1965
|
+
const data = await doChatRead(config, file, { room: roomId, limit });
|
|
1966
|
+
spin.stop(`Read ${data.messages.length} local messages`);
|
|
1967
|
+
prompts.note(formatMessagesForNote(data.messages), `Recent messages (${roomId})`);
|
|
1968
|
+
return data;
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
spin.stop('Read failed');
|
|
1971
|
+
throw err;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async function promptSearchMessages(config, file, prompts, options = {}) {
|
|
1976
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
1977
|
+
message: 'Choose room to search',
|
|
1978
|
+
requireKey: true,
|
|
1979
|
+
includeCreate: false,
|
|
1980
|
+
includeJoin: true,
|
|
1981
|
+
});
|
|
1982
|
+
const query = await prompts.text({
|
|
1983
|
+
message: 'Search query',
|
|
1984
|
+
placeholder: 'auth decisions, current blocker, setup note...',
|
|
1985
|
+
validate: promptRequired,
|
|
1986
|
+
});
|
|
1987
|
+
if (prompts.isCancel(query)) throw new Error('cancelled');
|
|
1988
|
+
const mode = await prompts.select({
|
|
1989
|
+
message: 'Search mode',
|
|
1990
|
+
options: [
|
|
1991
|
+
{ value: 'hybrid', label: 'Hybrid', hint: 'keyword + BGE vector' },
|
|
1992
|
+
{ value: 'keyword', label: 'Keyword', hint: 'SQLite FTS/BM25' },
|
|
1993
|
+
{ value: 'vector', label: 'Vector', hint: 'local BGE cosine search' },
|
|
1994
|
+
],
|
|
1995
|
+
});
|
|
1996
|
+
if (prompts.isCancel(mode)) throw new Error('cancelled');
|
|
1997
|
+
const limit = await prompts.select({
|
|
1998
|
+
message: 'Result limit',
|
|
1999
|
+
options: [
|
|
2000
|
+
{ value: 10, label: '10 results' },
|
|
2001
|
+
{ value: 20, label: '20 results' },
|
|
2002
|
+
{ value: 50, label: '50 results' },
|
|
2003
|
+
],
|
|
2004
|
+
});
|
|
2005
|
+
if (prompts.isCancel(limit)) throw new Error('cancelled');
|
|
2006
|
+
const spin = prompts.spinner();
|
|
2007
|
+
spin.start('Syncing, embedding locally, and searching');
|
|
2008
|
+
try {
|
|
2009
|
+
const data = await doChatSearch(config, file, { room: roomId, query: String(query), mode, limit });
|
|
2010
|
+
spin.stop(`Found ${data.results.length} result(s)`);
|
|
2011
|
+
prompts.note(formatSearchForNote(data.results), `Search results (${mode})`);
|
|
2012
|
+
return data;
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
spin.stop('Search failed');
|
|
2015
|
+
throw err;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
async function promptSyncRoom(config, file, prompts, options = {}) {
|
|
2020
|
+
const roomId = options.roomId || await selectRoom(config, file, prompts, {
|
|
2021
|
+
message: 'Choose room to sync',
|
|
2022
|
+
requireKey: true,
|
|
2023
|
+
includeCreate: false,
|
|
2024
|
+
includeJoin: true,
|
|
2025
|
+
});
|
|
2026
|
+
const spin = prompts.spinner();
|
|
2027
|
+
spin.start('Syncing encrypted room transcript into local SQLite');
|
|
2028
|
+
try {
|
|
2029
|
+
const data = await syncRoom(config, file, roomId);
|
|
2030
|
+
spin.stop(`Pulled ${data.pulled} message(s)`);
|
|
2031
|
+
prompts.note(`Local DB: ${data.db}\nLast message ID: ${data.last_message_id}\nChunks embedded: ${data.embedding?.chunks_embedded || 0}`, 'Sync complete');
|
|
2032
|
+
return data;
|
|
2033
|
+
} catch (err) {
|
|
2034
|
+
spin.stop('Sync failed');
|
|
2035
|
+
throw err;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
async function promptRoomActions(config, file, prompts, roomId) {
|
|
2040
|
+
while (true) {
|
|
2041
|
+
const action = await prompts.select({
|
|
2042
|
+
message: `Room ${roomId}`,
|
|
2043
|
+
options: [
|
|
2044
|
+
{ value: 'activate', label: 'Activate for a project directory' },
|
|
2045
|
+
{ value: 'invite', label: 'Create private invite' },
|
|
2046
|
+
{ value: 'send', label: 'Send message' },
|
|
2047
|
+
{ value: 'read', label: 'Read recent messages' },
|
|
2048
|
+
{ value: 'search', label: 'Search transcript' },
|
|
2049
|
+
{ value: 'sync', label: 'Sync locally' },
|
|
2050
|
+
{ value: 'back', label: 'Back' },
|
|
2051
|
+
],
|
|
2052
|
+
});
|
|
2053
|
+
if (prompts.isCancel(action) || action === 'back') return;
|
|
2054
|
+
try {
|
|
2055
|
+
if (action === 'activate') await promptActivateRoom(config, file, prompts, { roomId });
|
|
2056
|
+
if (action === 'invite') await promptCreateInvite(config, file, prompts, { roomId });
|
|
2057
|
+
if (action === 'send') await promptSendMessage(config, file, prompts, { roomId });
|
|
2058
|
+
if (action === 'read') await promptReadMessages(config, file, prompts, { roomId });
|
|
2059
|
+
if (action === 'search') await promptSearchMessages(config, file, prompts, { roomId });
|
|
2060
|
+
if (action === 'sync') await promptSyncRoom(config, file, prompts, { roomId });
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
if (err.message !== 'cancelled') prompts.note(err.message || String(err), 'Action failed');
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function promptBrowseRooms(config, file, prompts) {
|
|
2068
|
+
const roomId = await selectRoom(config, file, prompts, {
|
|
2069
|
+
message: 'Choose room',
|
|
2070
|
+
requireKey: false,
|
|
2071
|
+
includeCreate: true,
|
|
2072
|
+
includeJoin: true,
|
|
2073
|
+
includeManual: true,
|
|
2074
|
+
});
|
|
2075
|
+
const ok = await ensureRoomKeyFromMenu(config, file, prompts, roomId);
|
|
2076
|
+
if (!ok) return promptBrowseRooms(config, file, prompts);
|
|
2077
|
+
return promptRoomActions(config, file, prompts, roomId);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
async function promptAccountStatus(config, file, prompts) {
|
|
2081
|
+
let me = null;
|
|
2082
|
+
try {
|
|
2083
|
+
me = config.userToken ? await api(config, 'GET', '/v1/me', undefined, config.userToken) : null;
|
|
2084
|
+
} catch {}
|
|
2085
|
+
const active = await activeStatus(config, file, {});
|
|
2086
|
+
const rooms = await listRoomsForMenu(config).catch(() => []);
|
|
2087
|
+
const lines = [
|
|
2088
|
+
`Server: ${config.serverUrl || DEFAULT_SERVER}`,
|
|
2089
|
+
`User: ${config.username || me?.username || 'not logged in'}`,
|
|
2090
|
+
`Agent: ${config.agentLabel || config.agentId || 'not registered'}`,
|
|
2091
|
+
`Fingerprint: ${config.agentFingerprint || 'none'}`,
|
|
2092
|
+
`Rooms visible: ${rooms.length}`,
|
|
2093
|
+
`Current directory: ${active.active ? `active in ${active.room_id}` : 'SuperCollab off'}`,
|
|
2094
|
+
`Config: ${file}`,
|
|
2095
|
+
];
|
|
2096
|
+
prompts.note(lines.join('\n'), 'Account and config');
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
async function promptMcpConfig(config, file, prompts) {
|
|
2100
|
+
const client = await prompts.select({
|
|
2101
|
+
message: 'MCP client config',
|
|
2102
|
+
options: [
|
|
2103
|
+
{ value: 'codex', label: 'Codex', hint: 'TOML config snippet' },
|
|
2104
|
+
{ value: 'claude', label: 'Claude', hint: 'JSON config snippet' },
|
|
2105
|
+
{ value: 'manual', label: 'Manual', hint: 'stdio command' },
|
|
2106
|
+
{ value: 'back', label: 'Back' },
|
|
2107
|
+
],
|
|
2108
|
+
});
|
|
2109
|
+
if (prompts.isCancel(client) || client === 'back') return;
|
|
2110
|
+
prompts.note(mcpConfigText(client, file), `${client} MCP config`);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
function sessionsFromResponse(data) {
|
|
2114
|
+
if (Array.isArray(data)) return data;
|
|
2115
|
+
if (Array.isArray(data?.sessions)) return data.sessions;
|
|
2116
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
2117
|
+
return [];
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function sessionIdFrom(session) {
|
|
2121
|
+
return trimmed(session?.session_id || session?.id || session?.token_id);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
async function promptManageSessions(config, prompts) {
|
|
2125
|
+
const data = await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken);
|
|
2126
|
+
const sessions = sessionsFromResponse(data).filter((session) => sessionIdFrom(session));
|
|
2127
|
+
if (!sessions.length) {
|
|
2128
|
+
prompts.note('No active sessions returned by the server.', 'Sessions');
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const selected = await prompts.select({
|
|
2132
|
+
message: 'Choose session to revoke',
|
|
2133
|
+
options: [
|
|
2134
|
+
...sessions.map((session) => {
|
|
2135
|
+
const id = sessionIdFrom(session);
|
|
2136
|
+
const label = `${shorten(session.agent_label || session.label || id, 36)} (${id})`;
|
|
2137
|
+
const hint = [session.created_at, session.expires_at ? `expires ${session.expires_at}` : null].filter(Boolean).join(' - ');
|
|
2138
|
+
return { value: id, label, hint };
|
|
2139
|
+
}),
|
|
2140
|
+
{ value: '__manual', label: 'Type session ID manually' },
|
|
2141
|
+
{ value: '__back', label: 'Back' },
|
|
2142
|
+
],
|
|
2143
|
+
});
|
|
2144
|
+
if (prompts.isCancel(selected) || selected === '__back') return;
|
|
2145
|
+
const sessionId = selected === '__manual'
|
|
2146
|
+
? await prompts.text({ message: 'Session ID', validate: promptRequired })
|
|
2147
|
+
: selected;
|
|
2148
|
+
if (prompts.isCancel(sessionId)) throw new Error('cancelled');
|
|
2149
|
+
const confirm = await prompts.confirm({ message: `Revoke session ${sessionId}?`, initialValue: false });
|
|
2150
|
+
if (prompts.isCancel(confirm) || !confirm) return;
|
|
2151
|
+
await api(config, 'DELETE', `/v1/agent-sessions/${encodeURIComponent(String(sessionId))}`, undefined, config.userToken);
|
|
2152
|
+
prompts.note(`Revoked ${sessionId}`, 'Session revoked');
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
async function promptSetServerUrl(config, file, prompts) {
|
|
2156
|
+
const server = await prompts.text({
|
|
2157
|
+
message: 'SuperCollab server URL',
|
|
2158
|
+
defaultValue: config.serverUrl || DEFAULT_SERVER,
|
|
2159
|
+
placeholder: DEFAULT_SERVER,
|
|
2160
|
+
validate: (value) => {
|
|
2161
|
+
try {
|
|
2162
|
+
const parsed = new URL(String(value || ''));
|
|
2163
|
+
return parsed.protocol === 'https:' || parsed.hostname === 'localhost' ? undefined : 'Use https:// for remote servers';
|
|
2164
|
+
} catch {
|
|
2165
|
+
return 'Enter a valid URL';
|
|
2166
|
+
}
|
|
2167
|
+
},
|
|
2168
|
+
});
|
|
2169
|
+
if (prompts.isCancel(server)) throw new Error('cancelled');
|
|
2170
|
+
config.serverUrl = trimmed(server).replace(/\/$/, '');
|
|
2171
|
+
saveConfig(config, file);
|
|
2172
|
+
prompts.note(config.serverUrl, 'Server URL saved');
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
async function runRoomsMenu(config, file, prompts) {
|
|
2176
|
+
while (true) {
|
|
2177
|
+
const action = await prompts.select({
|
|
2178
|
+
message: 'Rooms',
|
|
2179
|
+
options: [
|
|
2180
|
+
{ value: 'browse', label: 'Browse/select room', hint: 'scroll rooms or type room ID' },
|
|
2181
|
+
{ value: 'create', label: 'Create room', hint: 'sets up backend room and local key' },
|
|
2182
|
+
{ value: 'join', label: 'Join with private invite', hint: 'saves room key locally' },
|
|
2183
|
+
{ value: 'invite', label: 'Create private invite' },
|
|
2184
|
+
{ value: 'back', label: 'Back' },
|
|
2185
|
+
],
|
|
2186
|
+
});
|
|
2187
|
+
if (prompts.isCancel(action) || action === 'back') return;
|
|
2188
|
+
try {
|
|
2189
|
+
if (action === 'browse') await promptBrowseRooms(config, file, prompts);
|
|
2190
|
+
if (action === 'create') {
|
|
2191
|
+
const created = await promptCreateRoom(config, file, prompts);
|
|
2192
|
+
if (created?.room_id) await promptRoomActions(config, file, prompts, created.room_id);
|
|
2193
|
+
}
|
|
2194
|
+
if (action === 'join') {
|
|
2195
|
+
const joined = await promptJoinRoom(config, file, prompts);
|
|
2196
|
+
if (joined?.room_id) await promptRoomActions(config, file, prompts, joined.room_id);
|
|
2197
|
+
}
|
|
2198
|
+
if (action === 'invite') await promptCreateInvite(config, file, prompts);
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
if (err.message !== 'cancelled') prompts.note(err.message || String(err), 'Room action failed');
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
async function runChatMenu(config, file, prompts) {
|
|
2206
|
+
while (true) {
|
|
2207
|
+
const action = await prompts.select({
|
|
2208
|
+
message: 'Chat',
|
|
2209
|
+
options: [
|
|
2210
|
+
{ value: 'send', label: 'Send message/note' },
|
|
2211
|
+
{ value: 'read', label: 'Read recent messages' },
|
|
2212
|
+
{ value: 'search', label: 'Search transcript' },
|
|
2213
|
+
{ value: 'sync', label: 'Sync room locally' },
|
|
2214
|
+
{ value: 'back', label: 'Back' },
|
|
2215
|
+
],
|
|
2216
|
+
});
|
|
2217
|
+
if (prompts.isCancel(action) || action === 'back') return;
|
|
2218
|
+
try {
|
|
2219
|
+
if (action === 'send') await promptSendMessage(config, file, prompts);
|
|
2220
|
+
if (action === 'read') await promptReadMessages(config, file, prompts);
|
|
2221
|
+
if (action === 'search') await promptSearchMessages(config, file, prompts);
|
|
2222
|
+
if (action === 'sync') await promptSyncRoom(config, file, prompts);
|
|
2223
|
+
} catch (err) {
|
|
2224
|
+
if (err.message !== 'cancelled') prompts.note(err.message || String(err), 'Chat action failed');
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
async function runWorkspaceMenu(config, file, prompts) {
|
|
2230
|
+
while (true) {
|
|
2231
|
+
const active = await activeStatus(config, file, {});
|
|
2232
|
+
const action = await prompts.select({
|
|
2233
|
+
message: `Workspace activation (${active.active ? `active: ${active.room_id}` : 'off'})`,
|
|
2234
|
+
options: [
|
|
2235
|
+
{ value: 'status', label: 'Show current status' },
|
|
2236
|
+
{ value: 'activate', label: 'Activate this directory' },
|
|
2237
|
+
{ value: 'activate_other', label: 'Activate another directory' },
|
|
2238
|
+
{ value: 'deactivate', label: 'Deactivate directory' },
|
|
2239
|
+
{ value: 'back', label: 'Back' },
|
|
2240
|
+
],
|
|
2241
|
+
});
|
|
2242
|
+
if (prompts.isCancel(action) || action === 'back') return;
|
|
2243
|
+
try {
|
|
2244
|
+
if (action === 'status') prompts.note(active.instructions, 'Current workspace');
|
|
2245
|
+
if (action === 'activate') await promptActivateRoom(config, file, prompts, { cwd: process.cwd() });
|
|
2246
|
+
if (action === 'activate_other') await promptActivateRoom(config, file, prompts);
|
|
2247
|
+
if (action === 'deactivate') await promptDeactivateRoom(config, file, prompts, { cwd: process.cwd() });
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
if (err.message !== 'cancelled') prompts.note(err.message || String(err), 'Workspace action failed');
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
async function runSettingsMenu(config, file, prompts) {
|
|
2255
|
+
while (true) {
|
|
2256
|
+
const action = await prompts.select({
|
|
2257
|
+
message: 'Settings',
|
|
2258
|
+
options: [
|
|
2259
|
+
{ value: 'doctor', label: 'System check / install BGE model' },
|
|
2260
|
+
{ value: 'account', label: 'Account and config status' },
|
|
2261
|
+
{ value: 'mcp', label: 'Show MCP config' },
|
|
2262
|
+
{ value: 'sessions', label: 'Manage sessions' },
|
|
2263
|
+
{ value: 'server', label: 'Set server URL' },
|
|
2264
|
+
{ value: 'back', label: 'Back' },
|
|
2265
|
+
],
|
|
2266
|
+
});
|
|
2267
|
+
if (prompts.isCancel(action) || action === 'back') return;
|
|
2268
|
+
try {
|
|
2269
|
+
if (action === 'doctor') await promptSystemCheck(config, file, prompts);
|
|
2270
|
+
if (action === 'account') await promptAccountStatus(config, file, prompts);
|
|
2271
|
+
if (action === 'mcp') await promptMcpConfig(config, file, prompts);
|
|
2272
|
+
if (action === 'sessions') await promptManageSessions(config, prompts);
|
|
2273
|
+
if (action === 'server') await promptSetServerUrl(config, file, prompts);
|
|
2274
|
+
} catch (err) {
|
|
2275
|
+
if (err.message !== 'cancelled') prompts.note(err.message || String(err), 'Settings action failed');
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
async function runMainMenu(config, file, opts = {}) {
|
|
2281
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2282
|
+
throw new Error('interactive menu requires a TTY');
|
|
2283
|
+
}
|
|
2284
|
+
if (!isConfigured(config)) {
|
|
2285
|
+
return runSetupWizard(config, file, opts);
|
|
2286
|
+
}
|
|
2287
|
+
const prompts = await loadPrompts();
|
|
2288
|
+
prompts.intro(`SuperCollab ${VERSION}`);
|
|
2289
|
+
try {
|
|
2290
|
+
while (true) {
|
|
2291
|
+
const active = await activeStatus(config, file, {});
|
|
2292
|
+
const action = await prompts.select({
|
|
2293
|
+
message: `Main menu (${config.username || 'user'} - ${active.active ? `active ${active.room_id}` : 'workspace off'})`,
|
|
2294
|
+
options: [
|
|
2295
|
+
{ value: 'rooms', label: 'Rooms', hint: 'create, join, browse, invite' },
|
|
2296
|
+
{ value: 'chat', label: 'Chat', hint: 'send, read, search, sync' },
|
|
2297
|
+
{ value: 'workspace', label: 'Workspace activation', hint: 'turn SuperCollab on/off for directories' },
|
|
2298
|
+
{ value: 'settings', label: 'Settings', hint: 'doctor, MCP config, sessions, server URL' },
|
|
2299
|
+
{ value: 'setup', label: 'Run onboarding again' },
|
|
2300
|
+
{ value: 'exit', label: 'Exit' },
|
|
2301
|
+
],
|
|
2302
|
+
});
|
|
2303
|
+
if (prompts.isCancel(action) || action === 'exit') {
|
|
2304
|
+
prompts.outro('Done.');
|
|
2305
|
+
return { ok: true };
|
|
2306
|
+
}
|
|
2307
|
+
if (action === 'rooms') await runRoomsMenu(config, file, prompts);
|
|
2308
|
+
if (action === 'chat') await runChatMenu(config, file, prompts);
|
|
2309
|
+
if (action === 'workspace') await runWorkspaceMenu(config, file, prompts);
|
|
2310
|
+
if (action === 'settings') await runSettingsMenu(config, file, prompts);
|
|
2311
|
+
if (action === 'setup') return runSetupWizard(config, file, opts);
|
|
2312
|
+
}
|
|
2313
|
+
} catch (err) {
|
|
2314
|
+
prompts.cancel(err.message === 'cancelled' ? 'Menu closed.' : `Menu stopped: ${err.message}`);
|
|
2315
|
+
throw err;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1456
2319
|
async function runSetupWizard(config, file, opts = {}) {
|
|
1457
2320
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1458
2321
|
throw new Error('interactive setup requires a TTY; run `supercollab doctor --json` for non-interactive diagnostics');
|
|
@@ -1518,10 +2381,15 @@ async function main() {
|
|
|
1518
2381
|
config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
|
|
1519
2382
|
|
|
1520
2383
|
if (positionals.length === 0) {
|
|
1521
|
-
if (process.stdin.isTTY && process.stdout.isTTY)
|
|
2384
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2385
|
+
return isConfigured(config)
|
|
2386
|
+
? runMainMenu(config, file, opts)
|
|
2387
|
+
: runSetupWizard(config, file, opts);
|
|
2388
|
+
}
|
|
1522
2389
|
printHelp();
|
|
1523
2390
|
return;
|
|
1524
2391
|
}
|
|
2392
|
+
if (cmd === 'menu') return runMainMenu(config, file, opts);
|
|
1525
2393
|
if (cmd === 'setup') return runSetupWizard(config, file, opts);
|
|
1526
2394
|
if (cmd === 'doctor') {
|
|
1527
2395
|
const data = await runDoctor(config, file, opts);
|
|
@@ -1564,6 +2432,7 @@ async function main() {
|
|
|
1564
2432
|
if (sub === 'warmup') return console.log(JSON.stringify(await embeddingWarmup(), null, 2));
|
|
1565
2433
|
}
|
|
1566
2434
|
if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
|
|
2435
|
+
if (cmd === 'mcp' && sub === 'smoke') return console.log(JSON.stringify(await runMcpSmoke(opts), null, 2));
|
|
1567
2436
|
if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
|
|
1568
2437
|
throw new Error(`unknown command: ${positionals.join(' ')}`);
|
|
1569
2438
|
}
|