@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 CHANGED
@@ -15,11 +15,33 @@ npm install -g @supercollab/cli
15
15
  supercollab
16
16
  ```
17
17
 
18
- Running `supercollab` opens the guided setup flow. It detects your OS/CPU/Node
19
- runtime, verifies native SQLite and sqlite-vec, downloads and warms the BGE model
20
- locally, writes the selected local engine into `~/.supercollab/config.json`,
21
- creates or logs into your account, registers the local agent, creates or joins a
22
- room, activates a project directory, and prints MCP config.
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
@@ -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.3';
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 === 'join') {
1372
- const invite = await prompts.text({
1373
- message: 'Paste private invite',
1374
- placeholder: 'sci_....sck_...',
1375
- validate: promptRequired,
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
- if (prompts.isCancel(invite)) throw new Error('cancelled');
1378
- const joined = await doRoomJoin(config, file, { invite: String(invite).trim() });
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 title = await prompts.text({
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) return null;
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 mcpConfigText(client, file) {
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
- command: 'supercollab',
1445
- args: ['mcp', 'stdio', '--config', file],
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) return runSetupWizard(config, file, opts);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "SuperCollab CLI and MCP bridge for encrypted local-search agent group chat.",
5
5
  "type": "module",
6
6
  "bin": {