@supercollab/cli 0.1.0 → 0.1.2

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.
@@ -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 * as readlineCore from 'node:readline';
6
7
  import readline from 'node:readline/promises';
7
8
  import { stdin as input, stdout as output } from 'node:process';
8
9
 
9
- const VERSION = '0.1.0';
10
+ const VERSION = '0.1.2';
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;
@@ -17,11 +18,14 @@ function printHelp() {
17
18
  Usage:
18
19
  supercollab register --username NAME [--password PASS] [--server URL] [--label LABEL]
19
20
  supercollab login --username NAME [--password PASS] [--server URL]
21
+ supercollab menu
20
22
  supercollab whoami
21
23
  supercollab agent register [--label LABEL]
22
24
  supercollab workspace list
23
25
  supercollab workspace create --title TITLE --goal GOAL [--slug SLUG]
24
26
  supercollab workspace invite --workspace ID [--role member]
27
+ supercollab workspace invites --workspace ID
28
+ supercollab workspace invite-revoke --workspace ID --invite ID
25
29
  supercollab workspace join --invite TOKEN
26
30
  supercollab workspace message --workspace ID --text TEXT
27
31
  supercollab workspace messages --workspace ID [--limit 20]
@@ -29,6 +33,8 @@ Usage:
29
33
  supercollab workspace write --workspace ID --path PATH --content TEXT
30
34
  supercollab workspace read --workspace ID --path PATH
31
35
  supercollab workspace git --workspace ID
36
+ supercollab session list
37
+ supercollab session revoke --session ID
32
38
  supercollab heartbeat set --workspace ID --prompt TEXT [--interval 900]
33
39
  supercollab heartbeat tick --workspace ID
34
40
  supercollab mcp stdio
@@ -97,22 +103,49 @@ function requireValue(opts, key) {
97
103
 
98
104
  async function readPassword(prompt = 'Password: ') {
99
105
  if (process.env.SUPERCOLLAB_PASSWORD) return process.env.SUPERCOLLAB_PASSWORD;
100
- if (!process.stdin.isTTY) throw new Error('password required; pass --password or set SUPERCOLLAB_PASSWORD');
101
- const rl = readline.createInterface({ input, output });
102
- const stdin = process.stdin;
103
- const onData = (char) => {
104
- char = String(char);
105
- switch (char) {
106
- case '\n': case '\r': case '\u0004': stdin.pause(); break;
107
- default: output.write('\x1B[2K\x1B[200D' + prompt + '*'.repeat(rl.line.length)); break;
108
- }
109
- };
110
- stdin.on('data', onData);
111
- const pw = await rl.question(prompt);
112
- stdin.off('data', onData);
113
- rl.close();
114
- output.write('\n');
115
- return pw;
106
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
107
+ throw new Error('password required; pass --password or set SUPERCOLLAB_PASSWORD');
108
+ }
109
+ return await new Promise((resolve, reject) => {
110
+ let password = '';
111
+ const stdin = process.stdin;
112
+ const wasRaw = stdin.isRaw;
113
+ const cleanup = () => {
114
+ stdin.off('data', onData);
115
+ try { stdin.setRawMode(Boolean(wasRaw)); } catch {}
116
+ stdin.pause();
117
+ };
118
+ const onData = (chunk) => {
119
+ for (const char of chunk.toString('utf8')) {
120
+ if (char === '\u0003') {
121
+ cleanup();
122
+ output.write('\n');
123
+ reject(new Error('cancelled'));
124
+ return;
125
+ }
126
+ if (char === '\r' || char === '\n' || char === '\u0004') {
127
+ cleanup();
128
+ output.write('\n');
129
+ resolve(password);
130
+ return;
131
+ }
132
+ if (char === '\u007f' || char === '\b') {
133
+ if (password.length > 0) {
134
+ password = password.slice(0, -1);
135
+ output.write('\b \b');
136
+ }
137
+ continue;
138
+ }
139
+ password += char;
140
+ output.write('*');
141
+ }
142
+ };
143
+ output.write(prompt);
144
+ stdin.setEncoding('utf8');
145
+ stdin.setRawMode(true);
146
+ stdin.resume();
147
+ stdin.on('data', onData);
148
+ });
116
149
  }
117
150
 
118
151
  async function api(config, method, endpoint, body, token) {
@@ -233,6 +266,251 @@ async function doLogin(opts) {
233
266
  console.log(JSON.stringify({ ok: true, username: config.username, user_id: config.userId, config: file }, null, 2));
234
267
  }
235
268
 
269
+ async function askLine(prompt, defaultValue = '') {
270
+ const rl = readline.createInterface({ input, output });
271
+ try {
272
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
273
+ const answer = await rl.question(`${prompt}${suffix}: `);
274
+ return answer.trim() || defaultValue;
275
+ } finally {
276
+ rl.close();
277
+ }
278
+ }
279
+
280
+ async function pause() {
281
+ if (!process.stdin.isTTY) return;
282
+ await askLine('Press Enter to continue');
283
+ }
284
+
285
+ function clearScreen() {
286
+ if (process.stdout.isTTY) output.write('\x1b[2J\x1b[H');
287
+ }
288
+
289
+ async function selectMenu(title, items) {
290
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
291
+ items.forEach((item, idx) => console.log(`${idx + 1}. ${item.label}`));
292
+ const answer = await askLine('Choose number');
293
+ const idx = Number(answer) - 1;
294
+ return items[idx] || null;
295
+ }
296
+ readlineCore.emitKeypressEvents(input);
297
+ let selected = 0;
298
+ const render = () => {
299
+ output.write('\x1b[?25l\x1b[2J\x1b[H');
300
+ output.write(`${title}\n\n`);
301
+ for (let i = 0; i < items.length; i++) {
302
+ output.write(`${i === selected ? '> ' : ' '}${items[i].label}\n`);
303
+ }
304
+ output.write('\nUp/down to move, Enter to select, q to go back.\n');
305
+ };
306
+ return await new Promise((resolve, reject) => {
307
+ const wasRaw = input.isRaw;
308
+ const cleanup = () => {
309
+ input.off('keypress', onKey);
310
+ try { input.setRawMode(Boolean(wasRaw)); } catch {}
311
+ output.write('\x1b[?25h');
312
+ };
313
+ const onKey = (_str, key = {}) => {
314
+ if (key.ctrl && key.name === 'c') {
315
+ cleanup();
316
+ reject(new Error('cancelled'));
317
+ return;
318
+ }
319
+ if (key.name === 'up') {
320
+ selected = (selected - 1 + items.length) % items.length;
321
+ render();
322
+ } else if (key.name === 'down') {
323
+ selected = (selected + 1) % items.length;
324
+ render();
325
+ } else if (key.name === 'return') {
326
+ const item = items[selected];
327
+ cleanup();
328
+ resolve(item);
329
+ } else if (key.name === 'q' || key.name === 'escape') {
330
+ cleanup();
331
+ resolve(null);
332
+ }
333
+ };
334
+ input.setRawMode(true);
335
+ input.resume();
336
+ input.on('keypress', onKey);
337
+ render();
338
+ });
339
+ }
340
+
341
+ function printTable(rows, columns) {
342
+ if (!rows.length) {
343
+ console.log('No rows.');
344
+ return;
345
+ }
346
+ const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
347
+ console.log(columns.map((col, i) => col.label.padEnd(widths[i])).join(' '));
348
+ console.log(widths.map((w) => '-'.repeat(w)).join(' '));
349
+ for (const row of rows) {
350
+ console.log(columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
351
+ }
352
+ }
353
+
354
+ async function chooseWorkspace(config) {
355
+ const data = await callTool(config, 'workspace_list', {});
356
+ const workspaces = data.workspaces || [];
357
+ const items = workspaces.map((ws) => ({ label: `${ws.title} (${ws.id})`, workspace: ws }));
358
+ items.push({ label: 'Enter workspace id manually', manual: true });
359
+ items.push({ label: 'Back', back: true });
360
+ const choice = await selectMenu('Select Workspace', items);
361
+ if (!choice || choice.back) return null;
362
+ if (choice.manual) return { id: await askLine('Workspace id') };
363
+ return choice.workspace;
364
+ }
365
+
366
+ async function listInvites(config, workspaceId) {
367
+ const data = await apiAsAgent(config, 'GET', `/v1/workspaces/${workspaceId}/invites`);
368
+ printTable(data.invites || [], [
369
+ { label: 'id', value: (r) => r.id },
370
+ { label: 'role', value: (r) => r.role },
371
+ { label: 'status', value: (r) => r.status },
372
+ { label: 'expires', value: (r) => r.expires_at },
373
+ { label: 'created_by', value: (r) => r.created_by_username || r.created_by_user_id },
374
+ ]);
375
+ return data;
376
+ }
377
+
378
+ async function manageInvites(config) {
379
+ const workspace = await chooseWorkspace(config);
380
+ if (!workspace) return;
381
+ const workspaceId = workspace.id || workspace.workspace_id;
382
+ while (true) {
383
+ const choice = await selectMenu('Manage Invites', [
384
+ { label: 'List invites', action: 'list' },
385
+ { label: 'Create invite', action: 'create' },
386
+ { label: 'Revoke invite', action: 'revoke' },
387
+ { label: 'Back', action: 'back' },
388
+ ]);
389
+ if (!choice || choice.action === 'back') return;
390
+ clearScreen();
391
+ if (choice.action === 'list') {
392
+ await listInvites(config, workspaceId);
393
+ await pause();
394
+ } else if (choice.action === 'create') {
395
+ const role = await askLine('Role', 'member');
396
+ const ttl = Number(await askLine('TTL seconds', '86400'));
397
+ const data = await callTool(config, 'workspace_invite', { workspace_id: workspaceId, role, ttl_seconds: ttl });
398
+ console.log(JSON.stringify(data, null, 2));
399
+ await pause();
400
+ } else if (choice.action === 'revoke') {
401
+ const data = await apiAsAgent(config, 'GET', `/v1/workspaces/${workspaceId}/invites`);
402
+ const candidates = (data.invites || []).filter((invite) => invite.status === 'pending');
403
+ const items = candidates.map((invite) => ({ label: `${invite.id} ${invite.role} expires ${invite.expires_at}`, invite }));
404
+ items.push({ label: 'Enter invite id manually', manual: true });
405
+ items.push({ label: 'Back', back: true });
406
+ const selected = await selectMenu('Revoke Invite', items);
407
+ if (selected && !selected.back) {
408
+ const inviteId = selected.manual ? await askLine('Invite id') : selected.invite.id;
409
+ const result = await apiAsAgent(config, 'DELETE', `/v1/workspaces/${workspaceId}/invites/${inviteId}`);
410
+ console.log(JSON.stringify(result, null, 2));
411
+ await pause();
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ async function listSessions(config) {
418
+ const data = await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken);
419
+ printTable(data.sessions || [], [
420
+ { label: 'session', value: (r) => r.session_id },
421
+ { label: 'agent', value: (r) => r.label || r.agent_id },
422
+ { label: 'active', value: (r) => r.active ? 'yes' : 'no' },
423
+ { label: 'expires', value: (r) => r.expires_at },
424
+ { label: 'revoked', value: (r) => r.revoked_at || '' },
425
+ ]);
426
+ return data;
427
+ }
428
+
429
+ async function revokeSession(config, file, sessionId) {
430
+ const result = await api(config, 'DELETE', `/v1/agent-sessions/${sessionId}`, undefined, config.userToken);
431
+ delete config.agentSessionToken;
432
+ delete config.agentSessionExpiresAt;
433
+ saveConfig(config, file);
434
+ return result;
435
+ }
436
+
437
+ async function manageSessions(config, file) {
438
+ while (true) {
439
+ const choice = await selectMenu('Manage Sessions', [
440
+ { label: 'List sessions', action: 'list' },
441
+ { label: 'Revoke session', action: 'revoke' },
442
+ { label: 'Back', action: 'back' },
443
+ ]);
444
+ if (!choice || choice.action === 'back') return;
445
+ clearScreen();
446
+ if (choice.action === 'list') {
447
+ await listSessions(config);
448
+ await pause();
449
+ } else if (choice.action === 'revoke') {
450
+ const data = await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken);
451
+ const candidates = (data.sessions || []).filter((session) => session.active);
452
+ const items = candidates.map((session) => ({ label: `${session.session_id} ${session.label} expires ${session.expires_at}`, session }));
453
+ items.push({ label: 'Enter session id manually', manual: true });
454
+ items.push({ label: 'Back', back: true });
455
+ const selected = await selectMenu('Revoke Session', items);
456
+ if (selected && !selected.back) {
457
+ const sessionId = selected.manual ? await askLine('Session id') : selected.session.session_id;
458
+ console.log(JSON.stringify(await revokeSession(config, file, sessionId), null, 2));
459
+ await pause();
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ async function runMenu(opts) {
466
+ const file = configPath(opts);
467
+ const config = loadConfig(file);
468
+ config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
469
+ while (true) {
470
+ const label = config.username ? `SuperCollab - ${config.username}` : 'SuperCollab';
471
+ const choice = await selectMenu(label, [
472
+ { label: 'Who am I', action: 'whoami' },
473
+ { label: 'List workspaces', action: 'workspaces' },
474
+ { label: 'Create workspace', action: 'create_workspace' },
475
+ { label: 'Manage invites', action: 'invites' },
476
+ { label: 'Manage sessions', action: 'sessions' },
477
+ { label: 'Print Codex MCP config', action: 'mcp_config' },
478
+ { label: 'Exit', action: 'exit' },
479
+ ]);
480
+ if (!choice || choice.action === 'exit') {
481
+ clearScreen();
482
+ return;
483
+ }
484
+ clearScreen();
485
+ if (choice.action === 'whoami') {
486
+ console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
487
+ await pause();
488
+ } else if (choice.action === 'workspaces') {
489
+ const data = await callTool(config, 'workspace_list', {});
490
+ printTable(data.workspaces || [], [
491
+ { label: 'id', value: (r) => r.id },
492
+ { label: 'role', value: (r) => r.role },
493
+ { label: 'title', value: (r) => r.title },
494
+ ]);
495
+ await pause();
496
+ } else if (choice.action === 'create_workspace') {
497
+ const title = await askLine('Title');
498
+ const goal = await askLine('Goal');
499
+ const slug = await askLine('Slug', '');
500
+ const data = await callTool(config, 'workspace_create', { title, goal, slug: slug || undefined });
501
+ console.log(JSON.stringify(data, null, 2));
502
+ await pause();
503
+ } else if (choice.action === 'invites') {
504
+ await manageInvites(config);
505
+ } else if (choice.action === 'sessions') {
506
+ await manageSessions(config, file);
507
+ } else if (choice.action === 'mcp_config') {
508
+ printCodexConfig(opts);
509
+ await pause();
510
+ }
511
+ }
512
+ }
513
+
236
514
  function toolSchema(name, description, properties = {}, required = []) {
237
515
  return { name, description, inputSchema: { type: 'object', properties, required } };
238
516
  }
@@ -344,6 +622,7 @@ async function main() {
344
622
 
345
623
  if (cmd === 'register') return doRegister(opts);
346
624
  if (cmd === 'login') return doLogin(opts);
625
+ if (cmd === 'menu') return runMenu(opts);
347
626
  if (cmd === 'whoami') return console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
348
627
  if (cmd === 'config' && sub === 'path') return console.log(file);
349
628
  if (cmd === 'agent' && sub === 'register') {
@@ -353,10 +632,16 @@ async function main() {
353
632
  }
354
633
  if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
355
634
  if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
635
+ if (cmd === 'session') {
636
+ if (sub === 'list') return console.log(JSON.stringify(await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken), null, 2));
637
+ if (sub === 'revoke') return console.log(JSON.stringify(await revokeSession(config, file, requireValue(opts, 'session')), null, 2));
638
+ }
356
639
  if (cmd === 'workspace') {
357
640
  if (sub === 'list') return console.log(JSON.stringify(await callTool(config, 'workspace_list', {}), null, 2));
358
641
  if (sub === 'create') return console.log(JSON.stringify(await callTool(config, 'workspace_create', { title: requireValue(opts, 'title'), goal: requireValue(opts, 'goal'), slug: opts.slug }), null, 2));
359
642
  if (sub === 'invite') return console.log(JSON.stringify(await callTool(config, 'workspace_invite', { workspace_id: requireValue(opts, 'workspace'), role: opts.role || 'member', ttl_seconds: opts.ttl || 86400 }), null, 2));
643
+ if (sub === 'invites') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', `/v1/workspaces/${requireValue(opts, 'workspace')}/invites`), null, 2));
644
+ if (sub === 'invite-revoke') return console.log(JSON.stringify(await apiAsAgent(config, 'DELETE', `/v1/workspaces/${requireValue(opts, 'workspace')}/invites/${requireValue(opts, 'invite')}`), null, 2));
360
645
  if (sub === 'join') return console.log(JSON.stringify(await callTool(config, 'workspace_join', { invite_token: requireValue(opts, 'invite') }), null, 2));
361
646
  if (sub === 'message') return console.log(JSON.stringify(await callTool(config, 'workspace_message_send', { workspace_id: requireValue(opts, 'workspace'), text: requireValue(opts, 'text') }), null, 2));
362
647
  if (sub === 'messages') return console.log(JSON.stringify(await callTool(config, 'workspace_messages_read', { workspace_id: requireValue(opts, 'workspace'), limit: opts.limit || 20 }), null, 2));
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import readline from 'node:readline/promises';
7
+ import { stdin as input, stdout as output } from 'node:process';
8
+
9
+ const VERSION = '0.1.1';
10
+ const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
11
+ const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
12
+ const SESSION_TTL_SKEW = 60;
13
+
14
+ function printHelp() {
15
+ console.log(`SuperCollab CLI ${VERSION}
16
+
17
+ Usage:
18
+ supercollab register --username NAME [--password PASS] [--server URL] [--label LABEL]
19
+ supercollab login --username NAME [--password PASS] [--server URL]
20
+ supercollab whoami
21
+ supercollab agent register [--label LABEL]
22
+ supercollab workspace list
23
+ supercollab workspace create --title TITLE --goal GOAL [--slug SLUG]
24
+ supercollab workspace invite --workspace ID [--role member]
25
+ supercollab workspace join --invite TOKEN
26
+ supercollab workspace message --workspace ID --text TEXT
27
+ supercollab workspace messages --workspace ID [--limit 20]
28
+ supercollab workspace search --workspace ID --query TEXT
29
+ supercollab workspace write --workspace ID --path PATH --content TEXT
30
+ supercollab workspace read --workspace ID --path PATH
31
+ supercollab workspace git --workspace ID
32
+ supercollab heartbeat set --workspace ID --prompt TEXT [--interval 900]
33
+ supercollab heartbeat tick --workspace ID
34
+ supercollab mcp stdio
35
+ supercollab mcp print-config --client codex
36
+ supercollab config path
37
+
38
+ Options:
39
+ --config PATH Config path, default ~/.supercollab/config.json
40
+ --server URL SuperCollab API URL, default ${DEFAULT_SERVER}
41
+
42
+ Environment:
43
+ SUPERCOLLAB_PASSWORD can provide password non-interactively.
44
+ SUPERCOLLAB_CONFIG can override config path.
45
+ `);
46
+ }
47
+
48
+ function parse(argv) {
49
+ const positionals = [];
50
+ const opts = {};
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const arg = argv[i];
53
+ if (arg.startsWith('--')) {
54
+ const key = arg.slice(2);
55
+ const next = argv[i + 1];
56
+ if (!next || next.startsWith('--')) {
57
+ opts[key] = true;
58
+ } else {
59
+ opts[key] = next;
60
+ i++;
61
+ }
62
+ } else {
63
+ positionals.push(arg);
64
+ }
65
+ }
66
+ return { positionals, opts };
67
+ }
68
+
69
+ function configPath(opts = {}) {
70
+ return opts.config || DEFAULT_CONFIG;
71
+ }
72
+
73
+ function ensureConfigDir(file) {
74
+ const dir = path.dirname(file);
75
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
76
+ try { fs.chmodSync(dir, 0o700); } catch {}
77
+ }
78
+
79
+ function loadConfig(file = DEFAULT_CONFIG) {
80
+ if (!fs.existsSync(file)) return { serverUrl: DEFAULT_SERVER };
81
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
82
+ return { serverUrl: DEFAULT_SERVER, ...data };
83
+ }
84
+
85
+ function saveConfig(config, file = DEFAULT_CONFIG) {
86
+ ensureConfigDir(file);
87
+ const tmp = `${file}.${process.pid}.tmp`;
88
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
89
+ fs.renameSync(tmp, file);
90
+ try { fs.chmodSync(file, 0o600); } catch {}
91
+ }
92
+
93
+ function requireValue(opts, key) {
94
+ if (!opts[key] || opts[key] === true) throw new Error(`missing --${key}`);
95
+ return String(opts[key]);
96
+ }
97
+
98
+ async function readPassword(prompt = 'Password: ') {
99
+ if (process.env.SUPERCOLLAB_PASSWORD) return process.env.SUPERCOLLAB_PASSWORD;
100
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
101
+ throw new Error('password required; pass --password or set SUPERCOLLAB_PASSWORD');
102
+ }
103
+ return await new Promise((resolve, reject) => {
104
+ let password = '';
105
+ const stdin = process.stdin;
106
+ const wasRaw = stdin.isRaw;
107
+ const cleanup = () => {
108
+ stdin.off('data', onData);
109
+ try { stdin.setRawMode(Boolean(wasRaw)); } catch {}
110
+ stdin.pause();
111
+ };
112
+ const onData = (chunk) => {
113
+ for (const char of chunk.toString('utf8')) {
114
+ if (char === '\u0003') {
115
+ cleanup();
116
+ output.write('\n');
117
+ reject(new Error('cancelled'));
118
+ return;
119
+ }
120
+ if (char === '\r' || char === '\n' || char === '\u0004') {
121
+ cleanup();
122
+ output.write('\n');
123
+ resolve(password);
124
+ return;
125
+ }
126
+ if (char === '\u007f' || char === '\b') {
127
+ if (password.length > 0) {
128
+ password = password.slice(0, -1);
129
+ output.write('\b \b');
130
+ }
131
+ continue;
132
+ }
133
+ password += char;
134
+ output.write('*');
135
+ }
136
+ };
137
+ output.write(prompt);
138
+ stdin.setEncoding('utf8');
139
+ stdin.setRawMode(true);
140
+ stdin.resume();
141
+ stdin.on('data', onData);
142
+ });
143
+ }
144
+
145
+ async function api(config, method, endpoint, body, token) {
146
+ const server = (config.serverUrl || DEFAULT_SERVER).replace(/\/$/, '');
147
+ const headers = { 'content-type': 'application/json', 'user-agent': `supercollab-cli/${VERSION}` };
148
+ const auth = token || config.userToken || config.agentSessionToken;
149
+ if (auth) headers.authorization = `Bearer ${auth}`;
150
+ const res = await fetch(server + endpoint, {
151
+ method,
152
+ headers,
153
+ body: body === undefined ? undefined : JSON.stringify(body),
154
+ });
155
+ const text = await res.text();
156
+ let data = {};
157
+ if (text) {
158
+ try { data = JSON.parse(text); } catch { data = { text }; }
159
+ }
160
+ if (!res.ok) {
161
+ const detail = data.detail ? JSON.stringify(data.detail) : text;
162
+ throw new Error(`HTTP ${res.status} ${endpoint}: ${detail}`);
163
+ }
164
+ return data;
165
+ }
166
+
167
+ function generateAgentKeypair() {
168
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
169
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
170
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
171
+ const raw = publicKey.export({ type: 'spki', format: 'der' });
172
+ const digest = crypto.createHash('sha256').update(raw).digest().subarray(0, 10);
173
+ const fingerprint = 'ed25519:' + digest.toString('base64url').toLowerCase();
174
+ return { publicKeyPem, privateKeyPem, fingerprint };
175
+ }
176
+
177
+ function signRequest(privateKeyPem, method, endpoint, bodyString, timestamp, nonce) {
178
+ const bodyHash = crypto.createHash('sha256').update(bodyString).digest('hex');
179
+ const signing = Buffer.from(`${method.toUpperCase()}\n${endpoint}\n${bodyHash}\n${timestamp}\n${nonce}`);
180
+ const sig = crypto.sign(null, signing, crypto.createPrivateKey(privateKeyPem)).toString('base64url');
181
+ return sig + '='.repeat((4 - (sig.length % 4)) % 4);
182
+ }
183
+
184
+ async function ensureAgentSession(config) {
185
+ const now = Math.floor(Date.now() / 1000);
186
+ if (config.agentSessionToken && config.agentSessionExpiresAt && config.agentSessionExpiresAt - SESSION_TTL_SKEW > now) {
187
+ return config.agentSessionToken;
188
+ }
189
+ if (!config.agentId || !config.agentPrivateKeyPem) throw new Error('no local agent registered; run supercollab agent register');
190
+ const endpoint = '/v1/agent-sessions';
191
+ const bodyString = JSON.stringify({ agent_id: config.agentId });
192
+ const timestamp = String(now);
193
+ const nonce = crypto.randomBytes(18).toString('base64url');
194
+ const signature = signRequest(config.agentPrivateKeyPem, 'POST', endpoint, bodyString, timestamp, nonce);
195
+ const server = (config.serverUrl || DEFAULT_SERVER).replace(/\/$/, '');
196
+ const res = await fetch(server + endpoint, {
197
+ method: 'POST',
198
+ headers: {
199
+ 'content-type': 'application/json',
200
+ 'user-agent': `supercollab-cli/${VERSION}`,
201
+ 'x-supercollab-timestamp': timestamp,
202
+ 'x-supercollab-nonce': nonce,
203
+ 'x-supercollab-signature': signature,
204
+ },
205
+ body: bodyString,
206
+ });
207
+ const data = await res.json();
208
+ if (!res.ok) throw new Error(`agent session failed: ${JSON.stringify(data)}`);
209
+ config.agentSessionToken = data.token;
210
+ config.agentSessionExpiresAt = data.expires_at;
211
+ return data.token;
212
+ }
213
+
214
+ async function apiAsAgent(config, method, endpoint, body) {
215
+ const token = await ensureAgentSession(config);
216
+ return api(config, method, endpoint, body, token);
217
+ }
218
+
219
+ async function registerAgent(config, label) {
220
+ if (!config.userToken) throw new Error('login/register first');
221
+ const keys = generateAgentKeypair();
222
+ const data = await api(config, 'POST', '/v1/agents/register', { label, public_key_pem: keys.publicKeyPem }, config.userToken);
223
+ config.agentId = data.agent_id;
224
+ config.agentLabel = label;
225
+ config.agentFingerprint = data.fingerprint;
226
+ config.agentPrivateKeyPem = keys.privateKeyPem;
227
+ delete config.agentSessionToken;
228
+ delete config.agentSessionExpiresAt;
229
+ return data;
230
+ }
231
+
232
+ async function doRegister(opts) {
233
+ const file = configPath(opts);
234
+ const config = loadConfig(file);
235
+ config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
236
+ const username = requireValue(opts, 'username');
237
+ const password = opts.password ? String(opts.password) : await readPassword();
238
+ const data = await api(config, 'POST', '/v1/auth/register', { username, password }, null);
239
+ config.userId = data.user_id;
240
+ config.username = data.username;
241
+ config.userToken = data.token;
242
+ const agent = await registerAgent(config, String(opts.label || `${os.hostname()}-agent`));
243
+ saveConfig(config, file);
244
+ console.log(JSON.stringify({ ok: true, username: config.username, user_id: config.userId, agent_id: agent.agent_id, fingerprint: agent.fingerprint, config: file }, null, 2));
245
+ }
246
+
247
+ async function doLogin(opts) {
248
+ const file = configPath(opts);
249
+ const config = loadConfig(file);
250
+ config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
251
+ const username = requireValue(opts, 'username');
252
+ const password = opts.password ? String(opts.password) : await readPassword();
253
+ const data = await api(config, 'POST', '/v1/auth/login', { username, password }, null);
254
+ config.userId = data.user_id;
255
+ config.username = data.username;
256
+ config.userToken = data.token;
257
+ delete config.agentSessionToken;
258
+ delete config.agentSessionExpiresAt;
259
+ saveConfig(config, file);
260
+ console.log(JSON.stringify({ ok: true, username: config.username, user_id: config.userId, config: file }, null, 2));
261
+ }
262
+
263
+ function toolSchema(name, description, properties = {}, required = []) {
264
+ return { name, description, inputSchema: { type: 'object', properties, required } };
265
+ }
266
+
267
+ function mcpTools() {
268
+ const s = { type: 'string' };
269
+ return [
270
+ toolSchema('supercollab_status', 'Check SuperCollab server and authenticated actor status.'),
271
+ toolSchema('workspace_list', 'List workspaces visible to this agent.'),
272
+ toolSchema('workspace_create', 'Create a goal-scoped workspace.', { title: s, goal: s, slug: s }, ['title', 'goal']),
273
+ toolSchema('workspace_invite', 'Create a short-lived invite token.', { workspace_id: s, role: s, ttl_seconds: { type: 'integer' } }, ['workspace_id']),
274
+ toolSchema('workspace_join', 'Accept an invite token.', { invite_token: s, fingerprint: s }, ['invite_token']),
275
+ toolSchema('workspace_message_send', 'Send a workspace message.', { workspace_id: s, text: s }, ['workspace_id', 'text']),
276
+ toolSchema('workspace_messages_read', 'Read workspace events/messages.', { workspace_id: s, limit: { type: 'integer' } }, ['workspace_id']),
277
+ toolSchema('workspace_file_list', 'List workspace files.', { workspace_id: s, path: s }, ['workspace_id']),
278
+ toolSchema('workspace_file_read', 'Read a workspace file.', { workspace_id: s, path: s }, ['workspace_id', 'path']),
279
+ toolSchema('workspace_file_write', 'Write a workspace file and commit it.', { workspace_id: s, path: s, content: s, commit_message: s }, ['workspace_id', 'path', 'content']),
280
+ toolSchema('workspace_git_status', 'Show workspace Git status and recent commits.', { workspace_id: s }, ['workspace_id']),
281
+ toolSchema('workspace_task_create', 'Create a task.', { workspace_id: s, title: s, body: s, owner: s }, ['workspace_id', 'title']),
282
+ toolSchema('workspace_decision_record', 'Record a decision.', { workspace_id: s, title: s, decision: s, rationale: s }, ['workspace_id', 'title', 'decision']),
283
+ toolSchema('workspace_memory_reindex', 'Rebuild workspace search index.', { workspace_id: s }, ['workspace_id']),
284
+ toolSchema('workspace_memory_search', 'Search workspace memory.', { workspace_id: s, query: s, limit: { type: 'integer' } }, ['workspace_id', 'query']),
285
+ toolSchema('workspace_heartbeat_get', 'Read heartbeat config.', { workspace_id: s }, ['workspace_id']),
286
+ toolSchema('workspace_heartbeat_set', 'Set heartbeat config.', { workspace_id: s, prompt: s, interval_seconds: { type: 'integer' }, enabled: { type: 'boolean' } }, ['workspace_id', 'prompt']),
287
+ toolSchema('workspace_heartbeat_tick', 'Emit heartbeat now.', { workspace_id: s }, ['workspace_id']),
288
+ ];
289
+ }
290
+
291
+ async function callTool(config, name, args) {
292
+ if (name === 'supercollab_status') return { health: await api(config, 'GET', '/health'), me: await apiAsAgent(config, 'GET', '/v1/me') };
293
+ if (name === 'workspace_list') return apiAsAgent(config, 'GET', '/v1/workspaces');
294
+ if (name === 'workspace_create') return apiAsAgent(config, 'POST', '/v1/workspaces', { title: args.title, goal: args.goal, slug: args.slug });
295
+ if (name === 'workspace_invite') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/invites`, { role: args.role || 'member', ttl_seconds: args.ttl_seconds || 86400 });
296
+ if (name === 'workspace_join') return apiAsAgent(config, 'POST', '/v1/invites/accept', { token: args.invite_token, fingerprint: args.fingerprint || config.agentFingerprint });
297
+ if (name === 'workspace_message_send') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/messages`, { text: args.text });
298
+ if (name === 'workspace_messages_read') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/messages?limit=${encodeURIComponent(args.limit || 50)}`);
299
+ if (name === 'workspace_file_list') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/files?path=${encodeURIComponent(args.path || '')}`);
300
+ if (name === 'workspace_file_read') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/files/read?path=${encodeURIComponent(args.path)}`);
301
+ if (name === 'workspace_file_write') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/files/write`, { path: args.path, content: args.content, commit_message: args.commit_message });
302
+ if (name === 'workspace_git_status') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/git/status`);
303
+ if (name === 'workspace_task_create') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/tasks`, { title: args.title, body: args.body || '', owner: args.owner || 'unassigned' });
304
+ if (name === 'workspace_decision_record') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/decisions`, { title: args.title, decision: args.decision, rationale: args.rationale || '' });
305
+ if (name === 'workspace_memory_reindex') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/memory/reindex`, {});
306
+ if (name === 'workspace_memory_search') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/memory/search?q=${encodeURIComponent(args.query)}&limit=${encodeURIComponent(args.limit || 8)}`);
307
+ if (name === 'workspace_heartbeat_get') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/heartbeat`);
308
+ if (name === 'workspace_heartbeat_set') return apiAsAgent(config, 'PUT', `/v1/workspaces/${args.workspace_id}/heartbeat`, { prompt: args.prompt, interval_seconds: args.interval_seconds || 900, enabled: args.enabled !== false });
309
+ if (name === 'workspace_heartbeat_tick') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/heartbeat/tick`, {});
310
+ throw new Error(`unknown tool: ${name}`);
311
+ }
312
+
313
+ function writeRpc(payload) {
314
+ const raw = Buffer.from(JSON.stringify(payload));
315
+ process.stdout.write(`Content-Length: ${raw.length}\r\n\r\n`);
316
+ process.stdout.write(raw);
317
+ }
318
+
319
+ async function runMcp(opts) {
320
+ const config = loadConfig(configPath(opts));
321
+ let buffer = Buffer.alloc(0);
322
+ for await (const chunk of process.stdin) {
323
+ buffer = Buffer.concat([buffer, chunk]);
324
+ while (true) {
325
+ const headerEnd = buffer.indexOf('\r\n\r\n');
326
+ if (headerEnd < 0) break;
327
+ const header = buffer.subarray(0, headerEnd).toString();
328
+ const match = header.match(/content-length:\s*(\d+)/i);
329
+ if (!match) throw new Error('missing content-length');
330
+ const length = Number(match[1]);
331
+ const total = headerEnd + 4 + length;
332
+ if (buffer.length < total) break;
333
+ const body = buffer.subarray(headerEnd + 4, total).toString();
334
+ buffer = buffer.subarray(total);
335
+ const msg = JSON.parse(body);
336
+ const id = msg.id;
337
+ try {
338
+ let result;
339
+ if (msg.method === 'initialize') {
340
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'supercollab', version: VERSION } };
341
+ } else if (msg.method === 'tools/list') {
342
+ result = { tools: mcpTools() };
343
+ } else if (msg.method === 'tools/call') {
344
+ const data = await callTool(config, msg.params.name, msg.params.arguments || {});
345
+ result = { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
346
+ } else if (msg.method && msg.method.startsWith('notifications/')) {
347
+ continue;
348
+ } else {
349
+ throw new Error(`unsupported method: ${msg.method}`);
350
+ }
351
+ if (id !== undefined) writeRpc({ jsonrpc: '2.0', id, result });
352
+ } catch (err) {
353
+ if (id !== undefined) writeRpc({ jsonrpc: '2.0', id, error: { code: -32000, message: err.message } });
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ function printCodexConfig(opts) {
360
+ const file = configPath(opts);
361
+ console.log(`[mcp_servers.supercollab]\ncommand = "supercollab"\nargs = ["mcp", "stdio", "--config", "${file.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"]`);
362
+ }
363
+
364
+ async function main() {
365
+ const { positionals, opts } = parse(process.argv.slice(2));
366
+ if (opts.help || positionals.length === 0) { printHelp(); return; }
367
+ const [cmd, sub] = positionals;
368
+ const file = configPath(opts);
369
+ const config = loadConfig(file);
370
+ config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
371
+
372
+ if (cmd === 'register') return doRegister(opts);
373
+ if (cmd === 'login') return doLogin(opts);
374
+ if (cmd === 'whoami') return console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
375
+ if (cmd === 'config' && sub === 'path') return console.log(file);
376
+ if (cmd === 'agent' && sub === 'register') {
377
+ const data = await registerAgent(config, String(opts.label || `${os.hostname()}-agent`));
378
+ saveConfig(config, file);
379
+ return console.log(JSON.stringify({ ok: true, agent_id: data.agent_id, fingerprint: data.fingerprint, config: file }, null, 2));
380
+ }
381
+ if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
382
+ if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
383
+ if (cmd === 'workspace') {
384
+ if (sub === 'list') return console.log(JSON.stringify(await callTool(config, 'workspace_list', {}), null, 2));
385
+ if (sub === 'create') return console.log(JSON.stringify(await callTool(config, 'workspace_create', { title: requireValue(opts, 'title'), goal: requireValue(opts, 'goal'), slug: opts.slug }), null, 2));
386
+ if (sub === 'invite') return console.log(JSON.stringify(await callTool(config, 'workspace_invite', { workspace_id: requireValue(opts, 'workspace'), role: opts.role || 'member', ttl_seconds: opts.ttl || 86400 }), null, 2));
387
+ if (sub === 'join') return console.log(JSON.stringify(await callTool(config, 'workspace_join', { invite_token: requireValue(opts, 'invite') }), null, 2));
388
+ if (sub === 'message') return console.log(JSON.stringify(await callTool(config, 'workspace_message_send', { workspace_id: requireValue(opts, 'workspace'), text: requireValue(opts, 'text') }), null, 2));
389
+ if (sub === 'messages') return console.log(JSON.stringify(await callTool(config, 'workspace_messages_read', { workspace_id: requireValue(opts, 'workspace'), limit: opts.limit || 20 }), null, 2));
390
+ if (sub === 'search') return console.log(JSON.stringify(await callTool(config, 'workspace_memory_search', { workspace_id: requireValue(opts, 'workspace'), query: requireValue(opts, 'query'), limit: opts.limit || 8 }), null, 2));
391
+ if (sub === 'write') return console.log(JSON.stringify(await callTool(config, 'workspace_file_write', { workspace_id: requireValue(opts, 'workspace'), path: requireValue(opts, 'path'), content: requireValue(opts, 'content'), commit_message: opts.message }), null, 2));
392
+ if (sub === 'read') return console.log(JSON.stringify(await callTool(config, 'workspace_file_read', { workspace_id: requireValue(opts, 'workspace'), path: requireValue(opts, 'path') }), null, 2));
393
+ if (sub === 'git') return console.log(JSON.stringify(await callTool(config, 'workspace_git_status', { workspace_id: requireValue(opts, 'workspace') }), null, 2));
394
+ }
395
+ if (cmd === 'heartbeat') {
396
+ if (sub === 'set') return console.log(JSON.stringify(await callTool(config, 'workspace_heartbeat_set', { workspace_id: requireValue(opts, 'workspace'), prompt: requireValue(opts, 'prompt'), interval_seconds: opts.interval || 900, enabled: opts.enabled !== 'false' }), null, 2));
397
+ if (sub === 'tick') return console.log(JSON.stringify(await callTool(config, 'workspace_heartbeat_tick', { workspace_id: requireValue(opts, 'workspace') }), null, 2));
398
+ }
399
+ throw new Error(`unknown command: ${positionals.join(' ')}`);
400
+ }
401
+
402
+ main().catch((err) => {
403
+ console.error(`supercollab: ${err.message}`);
404
+ process.exit(1);
405
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "SuperCollab CLI and MCP bridge for secure agent collaboration workspaces.",
5
5
  "type": "module",
6
6
  "bin": {