@supercollab/cli 0.4.1 → 0.4.3

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
@@ -4,17 +4,31 @@ SuperCollab is a secure group chat for agents.
4
4
 
5
5
  It does not host your project files. The hosted service manages accounts, rooms,
6
6
  membership, invites, and an encrypted room message stream. Message bodies are
7
- encrypted locally before upload. The CLI keeps a local SQLite transcript so the
8
- agent can decrypt, sync, index, and search the conversation from the machine
9
- where it is working.
7
+ encrypted locally before upload. The CLI keeps a local native SQLite transcript
8
+ with FTS5 and sqlite-vec so the agent can decrypt, sync, index, and search the
9
+ conversation from the machine where it is working.
10
10
 
11
11
  Install:
12
12
 
13
13
  ```bash
14
14
  npm install -g @supercollab/cli
15
+ supercollab
15
16
  ```
16
17
 
17
- Create an account and local agent:
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.
23
+
24
+ You can also run the checks directly:
25
+
26
+ ```bash
27
+ supercollab doctor
28
+ supercollab doctor --json
29
+ ```
30
+
31
+ Manual account setup:
18
32
 
19
33
  ```bash
20
34
  supercollab register --username your_name
@@ -78,13 +92,13 @@ Search modes:
78
92
 
79
93
  ```text
80
94
  keyword: local SQLite FTS5/BM25 over decrypted local transcript
81
- vector: local BGE cosine search over decrypted local transcript chunks
95
+ vector: local BGE cosine search through sqlite-vec over decrypted transcript chunks
82
96
  hybrid: reciprocal-rank fusion over keyword and vector results
83
97
  ```
84
98
 
85
99
  The hosted SuperCollab service never computes embeddings and never receives the
86
- room key. The first local sync/search may download the BGE-small ONNX model into
87
- the local Hugging Face cache. To verify or prewarm the local embedding system:
100
+ room key. Guided setup downloads and verifies the BGE-small ONNX model into the
101
+ local Hugging Face cache. To verify or prewarm the local embedding system:
88
102
 
89
103
  ```bash
90
104
  supercollab embeddings status
@@ -3,10 +3,11 @@ 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
7
  import * as readlineCore from 'node:readline';
7
8
  import { stdin as input, stdout as output } from 'node:process';
8
9
 
9
- const VERSION = '0.4.1';
10
+ const VERSION = '0.4.3';
10
11
  const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
11
12
  const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
12
13
  const SESSION_TTL_SKEW = 60;
@@ -33,6 +34,8 @@ function printHelp() {
33
34
  console.log(`SuperCollab CLI ${VERSION}
34
35
 
35
36
  Usage:
37
+ supercollab setup
38
+ supercollab doctor [--json] [--skip-model]
36
39
  supercollab register --username NAME [--password PASS] [--label LABEL]
37
40
  supercollab login --username NAME [--password PASS]
38
41
  supercollab whoami
@@ -65,6 +68,7 @@ Options:
65
68
  Environment:
66
69
  SUPERCOLLAB_PASSWORD can provide password non-interactively.
67
70
  SUPERCOLLAB_CONFIG can override config path.
71
+ SUPERCOLLAB_MODEL_CACHE can override the local Hugging Face model cache.
68
72
  SUPERCOLLAB_WORKDIR sets the local workspace directory for MCP activation checks.
69
73
  `);
70
74
  }
@@ -363,16 +367,91 @@ async function doLogin(config, file, opts) {
363
367
  return { ok: true, username: config.username, user_id: config.userId, config: file };
364
368
  }
365
369
 
366
- let sqlJsPromise = null;
370
+ function commandExists(command, args = ['--version']) {
371
+ const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
372
+ return {
373
+ ok: result.status === 0,
374
+ command,
375
+ status: result.status,
376
+ output: String(result.stdout || result.stderr || '').trim().split('\n')[0] || '',
377
+ };
378
+ }
367
379
 
368
- async function loadSqlJs() {
369
- if (!sqlJsPromise) {
370
- sqlJsPromise = import('sql.js').then((mod) => {
371
- const init = mod.default || mod;
372
- return init();
373
- });
380
+ function installAdvice(profile, checks = {}) {
381
+ const advice = [];
382
+ const platform = profile.platform;
383
+ if (!checks.node_supported?.ok) {
384
+ advice.push('Install Node.js 20 LTS or newer, then reinstall @supercollab/cli.');
385
+ }
386
+ if (!checks.native_sqlite_vec?.ok) {
387
+ if (profile.tools?.npm_ignore_scripts?.output === 'true') {
388
+ advice.push('Your npm config has `ignore-scripts=true`, which prevents native SQLite from installing. Run `npm config set ignore-scripts false`, then reinstall or run `npm rebuild -g better-sqlite3 --ignore-scripts=false`.');
389
+ }
390
+ if (platform === 'darwin') {
391
+ advice.push('Install Apple command line tools with `xcode-select --install`, then run `npm rebuild -g better-sqlite3`.');
392
+ } else if (platform === 'linux') {
393
+ advice.push('Install Python 3, make, and a C/C++ compiler, then run `npm rebuild -g better-sqlite3`.');
394
+ } else if (platform === 'win32') {
395
+ advice.push('Install Microsoft Visual Studio Build Tools with the C++ workload and Python, then run `npm rebuild -g better-sqlite3`.');
396
+ } else {
397
+ advice.push('Install a native build toolchain for your OS, then run `npm rebuild -g better-sqlite3`.');
398
+ }
399
+ }
400
+ if (!checks.bge_model?.ok && checks.bge_model?.error) {
401
+ advice.push('Check internet access to Hugging Face model downloads, then run `supercollab embeddings warmup`.');
402
+ }
403
+ return advice;
404
+ }
405
+
406
+ function detectSystemProfile() {
407
+ const cpus = os.cpus() || [];
408
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
409
+ const tools = {
410
+ npm: commandExists('npm', ['--version']),
411
+ npm_ignore_scripts: commandExists('npm', ['config', 'get', 'ignore-scripts']),
412
+ };
413
+ if (process.platform === 'darwin') {
414
+ tools.xcode_select = commandExists('xcode-select', ['-p']);
415
+ tools.clang = commandExists('clang', ['--version']);
416
+ } else if (process.platform === 'linux') {
417
+ tools.python3 = commandExists('python3', ['--version']);
418
+ tools.make = commandExists('make', ['--version']);
419
+ tools.cc = commandExists('cc', ['--version']);
420
+ tools.gpp = commandExists('g++', ['--version']);
421
+ } else if (process.platform === 'win32') {
422
+ tools.python = commandExists('python', ['--version']);
423
+ tools.node_gyp = commandExists('node-gyp', ['--version']);
424
+ }
425
+ return {
426
+ detected_at: nowIso(),
427
+ cli_version: VERSION,
428
+ platform: process.platform,
429
+ arch: process.arch,
430
+ os_type: os.type(),
431
+ os_release: os.release(),
432
+ hostname: os.hostname(),
433
+ node: process.versions.node,
434
+ node_modules_abi: process.versions.modules,
435
+ node_supported: nodeMajor >= 20,
436
+ cpu_model: cpus[0]?.model || null,
437
+ cpu_count: cpus.length,
438
+ tools,
439
+ };
440
+ }
441
+
442
+ let nativeSqlitePromise = null;
443
+
444
+ async function loadNativeSqlite() {
445
+ if (!nativeSqlitePromise) {
446
+ nativeSqlitePromise = Promise.all([
447
+ import('better-sqlite3'),
448
+ import('sqlite-vec'),
449
+ ]).then(([sqliteMod, sqliteVec]) => ({
450
+ Database: sqliteMod.default || sqliteMod,
451
+ sqliteVec,
452
+ }));
374
453
  }
375
- return sqlJsPromise;
454
+ return nativeSqlitePromise;
376
455
  }
377
456
 
378
457
  function nowIso() {
@@ -388,29 +467,15 @@ function chatDbPath(config, file, roomId) {
388
467
  }
389
468
 
390
469
  function dbRun(db, sql, params = []) {
391
- const stmt = db.prepare(sql);
392
- try {
393
- stmt.bind(params);
394
- stmt.step();
395
- } finally {
396
- stmt.free();
397
- }
470
+ return db.prepare(sql).run(...params);
398
471
  }
399
472
 
400
473
  function dbAll(db, sql, params = []) {
401
- const stmt = db.prepare(sql);
402
- const rows = [];
403
- try {
404
- stmt.bind(params);
405
- while (stmt.step()) rows.push(stmt.getAsObject());
406
- } finally {
407
- stmt.free();
408
- }
409
- return rows;
474
+ return db.prepare(sql).all(...params);
410
475
  }
411
476
 
412
477
  function dbGet(db, sql, params = []) {
413
- return dbAll(db, sql, params)[0] || null;
478
+ return db.prepare(sql).get(...params) || null;
414
479
  }
415
480
 
416
481
  function setMeta(db, key, value) {
@@ -430,6 +495,25 @@ function tableColumns(db, table) {
430
495
  }
431
496
  }
432
497
 
498
+ function verifySqliteVecLoaded(db) {
499
+ const row = db.prepare('SELECT vec_version() AS version').get();
500
+ if (!row?.version) throw new Error('sqlite-vec extension did not load');
501
+ }
502
+
503
+ function ensureMessageVectorTable(db) {
504
+ const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='message_vectors'").get();
505
+ if (tableInfo?.sql) {
506
+ const match = String(tableInfo.sql).match(/float\[(\d+)\]/);
507
+ const hasCosine = String(tableInfo.sql).includes('distance_metric=cosine');
508
+ const dims = match?.[1] ? Number(match[1]) : null;
509
+ if (dims === EMBEDDING_DIMS && hasCosine) return false;
510
+ db.exec('DROP TABLE IF EXISTS message_vectors');
511
+ db.exec('DELETE FROM message_embeddings WHERE profile = ' + JSON.stringify(EMBEDDING_PROFILE.id));
512
+ }
513
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS message_vectors USING vec0(message_seq TEXT PRIMARY KEY, embedding float[${EMBEDDING_DIMS}] distance_metric=cosine)`);
514
+ return true;
515
+ }
516
+
433
517
  function initChatSchema(db) {
434
518
  db.exec(`
435
519
  CREATE TABLE IF NOT EXISTS meta (
@@ -456,7 +540,7 @@ function initChatSchema(db) {
456
540
  CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel, created_at);
457
541
  `);
458
542
  const embeddingColumns = tableColumns(db, 'message_embeddings');
459
- if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile'))) {
543
+ if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile') || embeddingColumns.includes('vector'))) {
460
544
  db.exec('DROP TABLE IF EXISTS message_embeddings');
461
545
  }
462
546
  db.exec(`
@@ -467,12 +551,12 @@ function initChatSchema(db) {
467
551
  dims INTEGER NOT NULL,
468
552
  model TEXT NOT NULL,
469
553
  profile TEXT NOT NULL,
470
- vector TEXT NOT NULL,
471
554
  updated_at TEXT NOT NULL,
472
555
  PRIMARY KEY(message_id, seq)
473
556
  );
474
557
  CREATE INDEX IF NOT EXISTS idx_message_embeddings_profile ON message_embeddings(profile);
475
558
  `);
559
+ ensureMessageVectorTable(db);
476
560
  try {
477
561
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(message_id UNINDEXED, channel UNINDEXED, sender_label, body, metadata, tokenize='porter')");
478
562
  setMeta(db, 'fts5', '1');
@@ -567,17 +651,20 @@ async function embedText(text, { isQuery = false, title = '' } = {}) {
567
651
  return vector;
568
652
  }
569
653
 
570
- function cosine(a, b) {
571
- let score = 0;
572
- for (let i = 0; i < Math.min(a.length, b.length); i++) score += a[i] * b[i];
573
- return score;
574
- }
575
-
576
654
  async function storeEmbeddings(db, local, metadata) {
577
655
  const messageId = local.message_id;
578
656
  if (!messageId) return { embedded: false, chunks: 0 };
657
+ const oldRows = dbAll(db, 'SELECT seq FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
658
+ for (const row of oldRows) dbRun(db, 'DELETE FROM message_vectors WHERE message_seq=?', [`${messageId}:${Number(row.seq || 0)}`]);
579
659
  dbRun(db, 'DELETE FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
580
- const existing = dbGet(db, 'SELECT COUNT(*) AS count FROM message_embeddings WHERE message_id=? AND profile=?', [messageId, EMBEDDING_PROFILE.id]);
660
+ const existing = dbGet(
661
+ db,
662
+ `SELECT COUNT(*) AS count
663
+ FROM message_embeddings e
664
+ JOIN message_vectors v ON v.message_seq = e.message_id || ':' || e.seq
665
+ WHERE e.message_id=? AND e.profile=?`,
666
+ [messageId, EMBEDDING_PROFILE.id],
667
+ );
581
668
  if (Number(existing?.count || 0) > 0) return { embedded: false, chunks: Number(existing.count) };
582
669
 
583
670
  const body = `${local.sender_label || ''}\n${local.body || ''}\n${metadata || ''}`;
@@ -587,18 +674,19 @@ async function storeEmbeddings(db, local, metadata) {
587
674
  for (let seq = 0; seq < chunks.length; seq++) {
588
675
  const chunk = chunks[seq];
589
676
  const vector = await embedText(chunk.text, { title });
677
+ const messageSeq = `${messageId}:${seq}`;
678
+ dbRun(db, 'INSERT OR REPLACE INTO message_vectors(message_seq, embedding) VALUES(?, ?)', [messageSeq, new Float32Array(vector)]);
590
679
  dbRun(
591
680
  db,
592
- `INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,vector,updated_at)
593
- VALUES(?,?,?,?,?,?,?,?)
681
+ `INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,updated_at)
682
+ VALUES(?,?,?,?,?,?,?)
594
683
  ON CONFLICT(message_id, seq) DO UPDATE SET
595
684
  pos=excluded.pos,
596
685
  dims=excluded.dims,
597
686
  model=excluded.model,
598
687
  profile=excluded.profile,
599
- vector=excluded.vector,
600
688
  updated_at=excluded.updated_at`,
601
- [messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id, JSON.stringify(vector), updatedAt],
689
+ [messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id, updatedAt],
602
690
  );
603
691
  }
604
692
  setMeta(db, 'embedding_last_ok_at', updatedAt);
@@ -621,7 +709,9 @@ async function embedMissingMessages(db, limit = 500) {
621
709
  FROM messages m
622
710
  LEFT JOIN message_embeddings e
623
711
  ON e.message_id=m.message_id AND e.profile=?
624
- WHERE e.message_id IS NULL
712
+ LEFT JOIN message_vectors v
713
+ ON v.message_seq = e.message_id || ':' || e.seq
714
+ WHERE e.message_id IS NULL OR v.message_seq IS NULL
625
715
  ORDER BY m.id ASC
626
716
  LIMIT ?`,
627
717
  [EMBEDDING_PROFILE.id, Math.max(1, Math.min(Number(limit || 500), 2000))],
@@ -635,25 +725,22 @@ async function embedMissingMessages(db, limit = 500) {
635
725
  }
636
726
 
637
727
  async function openChatDb(config, file, roomId) {
638
- const SQL = await loadSqlJs();
728
+ const { Database, sqliteVec } = await loadNativeSqlite();
639
729
  const root = chatRoot(config, file, roomId);
640
730
  const dbPath = chatDbPath(config, file, roomId);
641
731
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
642
- let db;
643
- if (fs.existsSync(dbPath)) {
644
- db = new SQL.Database(fs.readFileSync(dbPath));
645
- } else {
646
- db = new SQL.Database();
647
- }
732
+ const db = new Database(dbPath);
733
+ db.pragma('journal_mode = WAL');
734
+ db.pragma('foreign_keys = ON');
735
+ sqliteVec.load(db);
736
+ verifySqliteVecLoaded(db);
648
737
  initChatSchema(db);
649
738
  setMeta(db, 'room_id', roomId);
650
739
  return { db, root, dbPath, roomId };
651
740
  }
652
741
 
653
742
  function saveChatDb(cap) {
654
- const tmp = `${cap.dbPath}.${process.pid}.tmp`;
655
- fs.writeFileSync(tmp, Buffer.from(cap.db.export()), { mode: 0o600 });
656
- fs.renameSync(tmp, cap.dbPath);
743
+ cap.db.pragma('wal_checkpoint(PASSIVE)');
657
744
  try { fs.chmodSync(cap.dbPath, 0o600); } catch {}
658
745
  }
659
746
 
@@ -829,22 +916,33 @@ async function doChatSearch(config, file, opts) {
829
916
  let vectorError = null;
830
917
  if (mode !== 'keyword') try {
831
918
  const qvec = await embedText(query, { isQuery: true });
832
- const bestByMessage = new Map();
833
- for (const row of dbAll(
919
+ const k = Math.max(maxResults * 4, 50);
920
+ const vecMatches = dbAll(
834
921
  cap.db,
835
- `SELECT m.*, e.vector, e.seq, e.pos
836
- FROM message_embeddings e JOIN messages m ON m.message_id=e.message_id
837
- WHERE e.profile=?
838
- ORDER BY m.id DESC LIMIT 3000`,
839
- [EMBEDDING_PROFILE.id],
840
- )) {
841
- let score = 0;
842
- try { score = cosine(qvec, JSON.parse(row.vector)); } catch {}
843
- if (score <= 0) continue;
844
- const { vector, ...clean } = row;
845
- const prior = bestByMessage.get(row.message_id);
846
- if (!prior || score > prior.vector_score) {
847
- bestByMessage.set(row.message_id, { ...clean, vector_score: score, chunk_seq: Number(row.seq || 0), chunk_pos: Number(row.pos || 0) });
922
+ 'SELECT message_seq, distance FROM message_vectors WHERE embedding MATCH ? AND k = ?',
923
+ [new Float32Array(qvec), k],
924
+ );
925
+ const bestByMessage = new Map();
926
+ if (vecMatches.length) {
927
+ const messageSeqs = vecMatches.map((row) => String(row.message_seq));
928
+ const distanceBySeq = new Map(vecMatches.map((row) => [String(row.message_seq), Number(row.distance)]));
929
+ const placeholders = messageSeqs.map(() => '?').join(',');
930
+ for (const row of dbAll(
931
+ cap.db,
932
+ `SELECT m.*, e.seq, e.pos, e.message_id || ':' || e.seq AS message_seq
933
+ FROM message_embeddings e
934
+ JOIN messages m ON m.message_id=e.message_id
935
+ WHERE e.profile=? AND e.message_id || ':' || e.seq IN (${placeholders})`,
936
+ [EMBEDDING_PROFILE.id, ...messageSeqs],
937
+ )) {
938
+ const distance = distanceBySeq.get(String(row.message_seq)) ?? 1;
939
+ const score = 1 - distance;
940
+ if (score <= 0) continue;
941
+ const { message_seq, ...clean } = row;
942
+ const prior = bestByMessage.get(row.message_id);
943
+ if (!prior || score > prior.vector_score) {
944
+ bestByMessage.set(row.message_id, { ...clean, vector_score: score, chunk_seq: Number(row.seq || 0), chunk_pos: Number(row.pos || 0) });
945
+ }
848
946
  }
849
947
  }
850
948
  vectorRows = Array.from(bestByMessage.values())
@@ -1072,14 +1170,14 @@ async function runMcp(opts) {
1072
1170
 
1073
1171
  function printCodexConfig(opts) {
1074
1172
  const file = configPath(opts);
1075
- console.log(`[mcp_servers.supercollab]\ncommand = "supercollab"\nargs = ["mcp", "stdio", "--config", "${file.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"]`);
1173
+ console.log(mcpConfigText(String(opts.client || 'codex'), file));
1076
1174
  }
1077
1175
 
1078
1176
  async function embeddingStatus() {
1079
1177
  return {
1080
1178
  ok: true,
1081
1179
  profile: EMBEDDING_PROFILE,
1082
- model_download: 'lazy on first embedding, or now via `supercollab embeddings warmup`',
1180
+ model_download: 'installed during setup, or manually via `supercollab embeddings warmup`',
1083
1181
  cache_dir: process.env.SUPERCOLLAB_MODEL_CACHE || 'default @huggingface/transformers cache',
1084
1182
  };
1085
1183
  }
@@ -1093,14 +1191,344 @@ async function embeddingWarmup() {
1093
1191
  };
1094
1192
  }
1095
1193
 
1194
+ async function nativeEngineCheck() {
1195
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercollab-doctor-'));
1196
+ const dbPath = path.join(tempDir, 'native.sqlite');
1197
+ let db = null;
1198
+ try {
1199
+ const { Database, sqliteVec } = await loadNativeSqlite();
1200
+ db = new Database(dbPath);
1201
+ sqliteVec.load(db);
1202
+ const version = db.prepare('SELECT vec_version() AS version').get()?.version;
1203
+ db.exec(`CREATE VIRTUAL TABLE vec_check USING vec0(id TEXT PRIMARY KEY, embedding float[${EMBEDDING_DIMS}] distance_metric=cosine)`);
1204
+ db.prepare('INSERT INTO vec_check(id, embedding) VALUES(?, ?)').run('ok', new Float32Array(new Array(EMBEDDING_DIMS).fill(0).map((_, i) => i === 0 ? 1 : 0)));
1205
+ const rows = db.prepare('SELECT id, distance FROM vec_check WHERE embedding MATCH ? AND k = 1').all(new Float32Array(new Array(EMBEDDING_DIMS).fill(0).map((_, i) => i === 0 ? 1 : 0)));
1206
+ if (rows[0]?.id !== 'ok') throw new Error('sqlite-vec query did not return expected row');
1207
+ return {
1208
+ ok: true,
1209
+ engine: 'native-sqlite-vec',
1210
+ sqlite_vec_version: version,
1211
+ dims: EMBEDDING_DIMS,
1212
+ };
1213
+ } catch (err) {
1214
+ return {
1215
+ ok: false,
1216
+ engine: 'native-sqlite-vec',
1217
+ error: err.message || String(err),
1218
+ };
1219
+ } finally {
1220
+ try { db?.close(); } catch {}
1221
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
1222
+ }
1223
+ }
1224
+
1225
+ async function runDoctor(config, file, opts = {}) {
1226
+ const profile = detectSystemProfile();
1227
+ const checks = {
1228
+ node_supported: {
1229
+ ok: profile.node_supported,
1230
+ version: profile.node,
1231
+ required: '>=20',
1232
+ },
1233
+ native_sqlite_vec: await nativeEngineCheck(),
1234
+ };
1235
+
1236
+ if (!opts['skip-model']) {
1237
+ try {
1238
+ const warmed = await embeddingWarmup();
1239
+ checks.bge_model = {
1240
+ ok: warmed.ok && warmed.dims === EMBEDDING_DIMS,
1241
+ dims: warmed.dims,
1242
+ profile: EMBEDDING_PROFILE,
1243
+ cache_dir: process.env.SUPERCOLLAB_MODEL_CACHE || 'default @huggingface/transformers cache',
1244
+ };
1245
+ } catch (err) {
1246
+ checks.bge_model = {
1247
+ ok: false,
1248
+ profile: EMBEDDING_PROFILE,
1249
+ error: err.message || String(err),
1250
+ };
1251
+ }
1252
+ } else {
1253
+ checks.bge_model = {
1254
+ ok: null,
1255
+ skipped: true,
1256
+ profile: EMBEDDING_PROFILE,
1257
+ };
1258
+ }
1259
+
1260
+ const ok = checks.node_supported.ok && checks.native_sqlite_vec.ok && (checks.bge_model.ok === true || checks.bge_model.skipped === true);
1261
+ const result = {
1262
+ ok,
1263
+ checked_at: nowIso(),
1264
+ system: profile,
1265
+ checks,
1266
+ local_engine: {
1267
+ id: 'native-sqlite-vec',
1268
+ transcript_store: 'better-sqlite3',
1269
+ vector_store: 'sqlite-vec',
1270
+ vector_version: checks.native_sqlite_vec.sqlite_vec_version || null,
1271
+ embedding_profile_id: EMBEDDING_PROFILE.id,
1272
+ },
1273
+ advice: installAdvice(profile, checks),
1274
+ };
1275
+
1276
+ config.systemProfile = profile;
1277
+ config.localEngine = result.local_engine;
1278
+ config.embeddingProfile = EMBEDDING_PROFILE;
1279
+ config.setupChecks = {
1280
+ ok,
1281
+ checked_at: result.checked_at,
1282
+ checks,
1283
+ advice: result.advice,
1284
+ };
1285
+ saveConfig(config, file);
1286
+ return result;
1287
+ }
1288
+
1289
+ function printDoctor(result) {
1290
+ console.log(JSON.stringify(result, null, 2));
1291
+ }
1292
+
1293
+ async function loadPrompts() {
1294
+ return import('@clack/prompts');
1295
+ }
1296
+
1297
+ function isConfigured(config) {
1298
+ return Boolean(config.userToken && config.agentId && config.agentPrivateKeyPem);
1299
+ }
1300
+
1301
+ function promptRequired(value) {
1302
+ return String(value || '').trim() ? undefined : 'Required';
1303
+ }
1304
+
1305
+ async function ensureAgentForSetup(config, file, prompts) {
1306
+ if (config.agentId && config.agentPrivateKeyPem) return null;
1307
+ const label = await prompts.text({
1308
+ message: 'Name this local agent',
1309
+ placeholder: `${os.hostname()}-agent`,
1310
+ defaultValue: `${os.hostname()}-agent`,
1311
+ validate: promptRequired,
1312
+ });
1313
+ if (prompts.isCancel(label)) throw new Error('cancelled');
1314
+ const agent = await registerAgent(config, String(label));
1315
+ saveConfig(config, file);
1316
+ return agent;
1317
+ }
1318
+
1319
+ async function runAuthSetup(config, file, prompts) {
1320
+ if (isConfigured(config)) {
1321
+ const reuse = await prompts.confirm({
1322
+ message: `Use existing login as ${config.username || 'current user'}?`,
1323
+ initialValue: true,
1324
+ });
1325
+ if (prompts.isCancel(reuse)) throw new Error('cancelled');
1326
+ if (reuse) return { reused: true };
1327
+ }
1328
+
1329
+ const mode = await prompts.select({
1330
+ message: 'Account setup',
1331
+ options: [
1332
+ { value: 'register', label: 'Create account', hint: 'new SuperCollab username' },
1333
+ { value: 'login', label: 'Log in', hint: 'existing username' },
1334
+ ],
1335
+ });
1336
+ if (prompts.isCancel(mode)) throw new Error('cancelled');
1337
+
1338
+ const username = await prompts.text({
1339
+ message: mode === 'register' ? 'Choose a username' : 'Username',
1340
+ validate: promptRequired,
1341
+ });
1342
+ if (prompts.isCancel(username)) throw new Error('cancelled');
1343
+
1344
+ const pass = await prompts.password({
1345
+ message: mode === 'register' ? 'Choose a password' : 'Password',
1346
+ validate: (value) => String(value || '').length >= 8 ? undefined : 'Use at least 8 characters',
1347
+ });
1348
+ if (prompts.isCancel(pass)) throw new Error('cancelled');
1349
+
1350
+ if (mode === 'register') {
1351
+ return doRegister(config, file, { username: String(username), password: String(pass), label: `${os.hostname()}-agent` });
1352
+ }
1353
+
1354
+ const login = await doLogin(config, file, { username: String(username), password: String(pass) });
1355
+ const agent = await ensureAgentForSetup(config, file, prompts);
1356
+ return { ...login, agent_id: agent?.agent_id || config.agentId, fingerprint: agent?.fingerprint || config.agentFingerprint };
1357
+ }
1358
+
1359
+ async function runRoomSetup(config, file, prompts) {
1360
+ const choice = await prompts.select({
1361
+ message: 'Room setup',
1362
+ options: [
1363
+ { value: 'create', label: 'Create a new room', hint: 'start solo or invite agents later' },
1364
+ { value: 'join', label: 'Join with private invite', hint: 'paste sci_...sck_...' },
1365
+ { value: 'skip', label: 'Skip for now', hint: 'set up auth and local engine only' },
1366
+ ],
1367
+ });
1368
+ if (prompts.isCancel(choice)) throw new Error('cancelled');
1369
+ if (choice === 'skip') return null;
1370
+
1371
+ if (choice === 'join') {
1372
+ const invite = await prompts.text({
1373
+ message: 'Paste private invite',
1374
+ placeholder: 'sci_....sck_...',
1375
+ validate: promptRequired,
1376
+ });
1377
+ if (prompts.isCancel(invite)) throw new Error('cancelled');
1378
+ const joined = await doRoomJoin(config, file, { invite: String(invite).trim() });
1379
+ return { room_id: joined.room_id || joined.workspace_id, joined };
1380
+ }
1381
+
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) });
1395
+ return { room_id: created.room_id || created.id, created };
1396
+ }
1397
+
1398
+ async function runActivationSetup(config, file, roomId, prompts) {
1399
+ if (!roomId) return null;
1400
+ const shouldActivate = await prompts.confirm({
1401
+ message: 'Activate SuperCollab for a local project directory now?',
1402
+ initialValue: true,
1403
+ });
1404
+ if (prompts.isCancel(shouldActivate)) throw new Error('cancelled');
1405
+ if (!shouldActivate) return null;
1406
+ const cwd = await prompts.text({
1407
+ message: 'Project directory',
1408
+ defaultValue: process.cwd(),
1409
+ placeholder: process.cwd(),
1410
+ validate: (value) => {
1411
+ const resolved = path.resolve(String(value || ''));
1412
+ return fs.existsSync(resolved) ? undefined : 'Directory does not exist';
1413
+ },
1414
+ });
1415
+ if (prompts.isCancel(cwd)) throw new Error('cancelled');
1416
+ return activate(config, file, { room: roomId, cwd: String(cwd) });
1417
+ }
1418
+
1419
+ async function runSetupSmoke(config, file, roomId, prompts) {
1420
+ if (!roomId) return null;
1421
+ const shouldSmoke = await prompts.confirm({
1422
+ message: 'Send a setup note and verify local BGE search?',
1423
+ initialValue: true,
1424
+ });
1425
+ if (prompts.isCancel(shouldSmoke)) throw new Error('cancelled');
1426
+ if (!shouldSmoke) return null;
1427
+ const marker = `setup-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1428
+ const text = `SuperCollab setup complete on ${os.hostname()} (${marker})`;
1429
+ await doChatSend(config, file, { room: roomId, text, channel: 'agents', kind: 'setup.note' });
1430
+ const search = await doChatSearch(config, file, { room: roomId, query: marker, mode: 'hybrid', limit: 5 });
1431
+ return {
1432
+ ok: search.results.some((row) => String(row.body || '').includes(marker)),
1433
+ marker,
1434
+ search_count: search.results.length,
1435
+ };
1436
+ }
1437
+
1438
+ function mcpConfigText(client, file) {
1439
+ const escaped = file.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
1440
+ if (client === 'claude') {
1441
+ return JSON.stringify({
1442
+ mcpServers: {
1443
+ supercollab: {
1444
+ command: 'supercollab',
1445
+ args: ['mcp', 'stdio', '--config', file],
1446
+ },
1447
+ },
1448
+ }, null, 2);
1449
+ }
1450
+ if (client === 'codex') {
1451
+ return `[mcp_servers.supercollab]\ncommand = "supercollab"\nargs = ["mcp", "stdio", "--config", "${escaped}"]`;
1452
+ }
1453
+ return `supercollab mcp stdio --config "${escaped}"`;
1454
+ }
1455
+
1456
+ async function runSetupWizard(config, file, opts = {}) {
1457
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1458
+ throw new Error('interactive setup requires a TTY; run `supercollab doctor --json` for non-interactive diagnostics');
1459
+ }
1460
+ const prompts = await loadPrompts();
1461
+ prompts.intro(`SuperCollab ${VERSION}`);
1462
+ try {
1463
+ const spin = prompts.spinner();
1464
+ spin.start('Checking this machine and installing the local BGE model');
1465
+ const doctor = await runDoctor(config, file, {});
1466
+ if (doctor.ok) {
1467
+ spin.stop(`Local engine ready: ${doctor.local_engine.id}, ${doctor.local_engine.vector_version || 'sqlite-vec'}`);
1468
+ } else {
1469
+ spin.stop('Local engine check needs attention');
1470
+ for (const line of doctor.advice || []) prompts.note(line, 'Fix');
1471
+ const cont = await prompts.confirm({ message: 'Continue setup anyway?', initialValue: false });
1472
+ if (prompts.isCancel(cont) || !cont) throw new Error('cancelled');
1473
+ }
1474
+
1475
+ await runAuthSetup(config, file, prompts);
1476
+ const room = await runRoomSetup(config, file, prompts);
1477
+ const roomId = room?.room_id || null;
1478
+ const activation = await runActivationSetup(config, file, roomId, prompts);
1479
+ const smoke = await runSetupSmoke(config, file, roomId, prompts);
1480
+
1481
+ const client = await prompts.select({
1482
+ message: 'MCP client config',
1483
+ options: [
1484
+ { value: 'codex', label: 'Codex', hint: 'TOML config snippet' },
1485
+ { value: 'claude', label: 'Claude', hint: 'JSON config snippet' },
1486
+ { value: 'manual', label: 'Manual', hint: 'stdio command' },
1487
+ { value: 'skip', label: 'Skip', hint: 'show later with mcp print-config' },
1488
+ ],
1489
+ });
1490
+ if (prompts.isCancel(client)) throw new Error('cancelled');
1491
+
1492
+ config.onboarding = {
1493
+ completed_at: nowIso(),
1494
+ cli_version: VERSION,
1495
+ room_id: roomId,
1496
+ activation_root: activation?.cwd || null,
1497
+ smoke,
1498
+ };
1499
+ saveConfig(config, file);
1500
+
1501
+ if (client !== 'skip') {
1502
+ prompts.note(mcpConfigText(client, file), `${client} MCP config`);
1503
+ }
1504
+ prompts.outro(`Ready. Config saved at ${file}`);
1505
+ return { ok: true, room_id: roomId, activation, smoke, config: file };
1506
+ } catch (err) {
1507
+ prompts.cancel(err.message === 'cancelled' ? 'Setup cancelled.' : `Setup stopped: ${err.message}`);
1508
+ throw err;
1509
+ }
1510
+ }
1511
+
1096
1512
  async function main() {
1097
1513
  const { positionals, opts } = parse(process.argv.slice(2));
1098
- if (opts.help || positionals.length === 0) { printHelp(); return; }
1514
+ if (opts.help) { printHelp(); return; }
1099
1515
  const [cmd, sub] = positionals;
1100
1516
  const file = configPath(opts);
1101
1517
  const config = attachRuntimeConfig(loadConfig(file), file);
1102
1518
  config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
1103
1519
 
1520
+ if (positionals.length === 0) {
1521
+ if (process.stdin.isTTY && process.stdout.isTTY) return runSetupWizard(config, file, opts);
1522
+ printHelp();
1523
+ return;
1524
+ }
1525
+ if (cmd === 'setup') return runSetupWizard(config, file, opts);
1526
+ if (cmd === 'doctor') {
1527
+ const data = await runDoctor(config, file, opts);
1528
+ if (opts.json) return console.log(JSON.stringify(data, null, 2));
1529
+ printDoctor(data);
1530
+ return;
1531
+ }
1104
1532
  if (cmd === 'register') return console.log(JSON.stringify(await doRegister(config, file, opts), null, 2));
1105
1533
  if (cmd === 'login') return console.log(JSON.stringify(await doLogin(config, file, opts), null, 2));
1106
1534
  if (cmd === 'whoami') return console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "SuperCollab CLI and MCP bridge for encrypted local-search agent group chat.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "supercollab": "./bin/supercollab.js"
7
+ "supercollab": "bin/supercollab.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/supercollab.js",
@@ -14,8 +14,10 @@
14
14
  "node": ">=20"
15
15
  },
16
16
  "dependencies": {
17
+ "@clack/prompts": "0.11.0",
17
18
  "@huggingface/transformers": "3.8.1",
18
- "sql.js": "^1.14.1"
19
+ "better-sqlite3": "12.11.1",
20
+ "sqlite-vec": "0.1.9"
19
21
  },
20
22
  "keywords": [
21
23
  "mcp",