@supercollab/cli 0.4.4 → 0.4.6

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
@@ -133,6 +133,34 @@ Print MCP config:
133
133
  supercollab mcp print-config --client codex
134
134
  ```
135
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
+
152
+ For Claude Code, install the MCP server from the project directory:
153
+
154
+ ```bash
155
+ cd /path/to/project
156
+ supercollab mcp install --client claude-code
157
+ ```
158
+
159
+ The installer first runs the MCP smoke check, then calls Claude Code's own
160
+ `claude mcp add` with absolute Node and CLI paths plus explicit `HOME`, `PATH`,
161
+ and `SUPERCOLLAB_WORKDIR` values. This avoids Homebrew, nvm, shell startup, and
162
+ GUI path differences.
163
+
136
164
  Default server: `https://hyper.polynode.dev`.
137
165
 
138
166
  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.4';
11
+ const VERSION = '0.4.6';
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;
@@ -59,7 +61,9 @@ Usage:
59
61
  supercollab embeddings status
60
62
  supercollab embeddings warmup
61
63
  supercollab mcp stdio
64
+ supercollab mcp install --client claude-code [--scope local] [--cwd PATH]
62
65
  supercollab mcp print-config --client codex
66
+ supercollab mcp smoke [--timeout 5000]
63
67
  supercollab config path
64
68
 
65
69
  Options:
@@ -1169,9 +1173,96 @@ async function runMcp(opts) {
1169
1173
  }
1170
1174
  }
1171
1175
 
1176
+ function encodeMcpMessage(msg) {
1177
+ const raw = Buffer.from(JSON.stringify(msg));
1178
+ return Buffer.concat([Buffer.from(`Content-Length: ${raw.length}\r\n\r\n`), raw]);
1179
+ }
1180
+
1181
+ function parseMcpMessages(state) {
1182
+ const messages = [];
1183
+ while (true) {
1184
+ const headerEnd = state.buffer.indexOf('\r\n\r\n');
1185
+ if (headerEnd < 0) break;
1186
+ const header = state.buffer.subarray(0, headerEnd).toString();
1187
+ const match = header.match(/content-length:\s*(\d+)/i);
1188
+ if (!match) {
1189
+ state.parseError = `missing content-length in MCP output: ${header.slice(0, 200)}`;
1190
+ break;
1191
+ }
1192
+ const length = Number(match[1]);
1193
+ const total = headerEnd + 4 + length;
1194
+ if (state.buffer.length < total) break;
1195
+ const body = state.buffer.subarray(headerEnd + 4, total).toString();
1196
+ state.buffer = state.buffer.subarray(total);
1197
+ try {
1198
+ messages.push(JSON.parse(body));
1199
+ } catch (err) {
1200
+ state.parseError = err.message || String(err);
1201
+ break;
1202
+ }
1203
+ }
1204
+ return messages;
1205
+ }
1206
+
1207
+ async function runMcpSmoke(opts) {
1208
+ const file = configPath(opts);
1209
+ const timeoutMs = Math.max(1000, Math.min(Number(opts.timeout || 5000), 30000));
1210
+ const args = [CLI_ENTRY, 'mcp', 'stdio', '--config', file];
1211
+ return await new Promise((resolve, reject) => {
1212
+ const child = spawn(process.execPath, args, {
1213
+ stdio: ['pipe', 'pipe', 'pipe'],
1214
+ env: {
1215
+ ...process.env,
1216
+ PATH: defaultPathEnv(),
1217
+ SUPERCOLLAB_CONFIG: file,
1218
+ },
1219
+ });
1220
+ const state = { buffer: Buffer.alloc(0), parseError: null };
1221
+ const responses = [];
1222
+ let stderr = '';
1223
+ let settled = false;
1224
+ const finish = (err, result) => {
1225
+ if (settled) return;
1226
+ settled = true;
1227
+ clearTimeout(timer);
1228
+ try { child.kill('SIGTERM'); } catch {}
1229
+ if (err) reject(err);
1230
+ else resolve(result);
1231
+ };
1232
+ const timer = setTimeout(() => {
1233
+ finish(new Error(`MCP smoke timed out after ${timeoutMs}ms. stderr: ${stderr.slice(0, 1000)}`));
1234
+ }, timeoutMs);
1235
+ child.on('error', (err) => finish(err));
1236
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
1237
+ child.stdout.on('data', (chunk) => {
1238
+ state.buffer = Buffer.concat([state.buffer, chunk]);
1239
+ for (const msg of parseMcpMessages(state)) responses.push(msg);
1240
+ if (state.parseError) {
1241
+ finish(new Error(state.parseError));
1242
+ return;
1243
+ }
1244
+ if (responses.some((msg) => msg.id === 1) && responses.some((msg) => msg.id === 2)) {
1245
+ const init = responses.find((msg) => msg.id === 1);
1246
+ const tools = responses.find((msg) => msg.id === 2);
1247
+ finish(null, {
1248
+ ok: Boolean(init?.result?.serverInfo && Array.isArray(tools?.result?.tools)),
1249
+ command: process.execPath,
1250
+ args,
1251
+ config: file,
1252
+ server_info: init?.result?.serverInfo || null,
1253
+ tools: (tools?.result?.tools || []).map((tool) => tool.name),
1254
+ stderr: stderr.trim() || null,
1255
+ });
1256
+ }
1257
+ });
1258
+ child.stdin.write(encodeMcpMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }));
1259
+ child.stdin.write(encodeMcpMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }));
1260
+ });
1261
+ }
1262
+
1172
1263
  function printCodexConfig(opts) {
1173
1264
  const file = configPath(opts);
1174
- console.log(mcpConfigText(String(opts.client || 'codex'), file));
1265
+ console.log(mcpConfigText(String(opts.client || 'codex'), file, opts));
1175
1266
  }
1176
1267
 
1177
1268
  async function embeddingStatus() {
@@ -1670,14 +1761,35 @@ async function runSetupSmoke(config, file, roomId, prompts) {
1670
1761
  };
1671
1762
  }
1672
1763
 
1673
- function mcpConfigText(client, file) {
1764
+ function defaultPathEnv() {
1765
+ const parts = [
1766
+ path.dirname(process.execPath),
1767
+ '/opt/homebrew/bin',
1768
+ '/usr/local/bin',
1769
+ '/usr/bin',
1770
+ '/bin',
1771
+ '/usr/sbin',
1772
+ '/sbin',
1773
+ process.env.PATH || '',
1774
+ ].flatMap((part) => String(part || '').split(path.delimiter)).filter(Boolean);
1775
+ return Array.from(new Set(parts)).join(path.delimiter);
1776
+ }
1777
+
1778
+ function mcpConfigText(client, file, opts = {}) {
1779
+ const absoluteFile = path.resolve(file);
1674
1780
  const escaped = file.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
1675
1781
  if (client === 'claude') {
1782
+ const cwd = path.resolve(String(opts.cwd || process.env.SUPERCOLLAB_WORKDIR || process.cwd()));
1676
1783
  return JSON.stringify({
1677
1784
  mcpServers: {
1678
1785
  supercollab: {
1679
- command: 'supercollab',
1680
- args: ['mcp', 'stdio', '--config', file],
1786
+ type: 'stdio',
1787
+ command: process.execPath,
1788
+ args: [CLI_ENTRY, 'mcp', 'stdio', '--config', absoluteFile],
1789
+ env: {
1790
+ PATH: defaultPathEnv(),
1791
+ SUPERCOLLAB_WORKDIR: cwd,
1792
+ },
1681
1793
  },
1682
1794
  },
1683
1795
  }, null, 2);
@@ -1685,9 +1797,120 @@ function mcpConfigText(client, file) {
1685
1797
  if (client === 'codex') {
1686
1798
  return `[mcp_servers.supercollab]\ncommand = "supercollab"\nargs = ["mcp", "stdio", "--config", "${escaped}"]`;
1687
1799
  }
1800
+ if (client === 'claude-code') {
1801
+ const install = claudeCodeInstallPlan(file, opts);
1802
+ return shellCommand(install.command, install.args);
1803
+ }
1688
1804
  return `supercollab mcp stdio --config "${escaped}"`;
1689
1805
  }
1690
1806
 
1807
+ function shellQuote(value) {
1808
+ const text = String(value);
1809
+ if (/^[A-Za-z0-9_./:=@%+-]+$/.test(text)) return text;
1810
+ return `'${text.replaceAll("'", "'\\''")}'`;
1811
+ }
1812
+
1813
+ function shellCommand(command, args = []) {
1814
+ return [command, ...args].map(shellQuote).join(' ');
1815
+ }
1816
+
1817
+ function claudeCodeMcpEnv(opts = {}) {
1818
+ const cwd = path.resolve(String(opts.cwd || process.env.SUPERCOLLAB_WORKDIR || process.cwd()));
1819
+ return {
1820
+ HOME: os.homedir(),
1821
+ PATH: defaultPathEnv(),
1822
+ SUPERCOLLAB_WORKDIR: cwd,
1823
+ };
1824
+ }
1825
+
1826
+ function claudeCodeInstallPlan(file, opts = {}) {
1827
+ const scope = String(opts.scope || 'local');
1828
+ const claude = String(opts.claude || 'claude');
1829
+ const env = claudeCodeMcpEnv(opts);
1830
+ const args = [
1831
+ 'mcp', 'add',
1832
+ '--scope', scope,
1833
+ '--transport', 'stdio',
1834
+ '--env', `HOME=${env.HOME}`,
1835
+ '--env', `PATH=${env.PATH}`,
1836
+ '--env', `SUPERCOLLAB_WORKDIR=${env.SUPERCOLLAB_WORKDIR}`,
1837
+ 'supercollab',
1838
+ '--',
1839
+ process.execPath,
1840
+ CLI_ENTRY,
1841
+ 'mcp',
1842
+ 'stdio',
1843
+ '--config',
1844
+ path.resolve(file),
1845
+ ];
1846
+ return { command: claude, args, env, scope, cwd: env.SUPERCOLLAB_WORKDIR };
1847
+ }
1848
+
1849
+ function runProcess(command, args, options = {}) {
1850
+ const result = spawnSync(command, args, {
1851
+ encoding: 'utf8',
1852
+ stdio: ['ignore', 'pipe', 'pipe'],
1853
+ ...options,
1854
+ });
1855
+ return {
1856
+ ok: result.status === 0,
1857
+ status: result.status,
1858
+ signal: result.signal,
1859
+ stdout: String(result.stdout || '').trim(),
1860
+ stderr: String(result.stderr || '').trim(),
1861
+ error: result.error?.message || null,
1862
+ };
1863
+ }
1864
+
1865
+ async function installClaudeCodeMcp(config, file, opts = {}) {
1866
+ const client = String(opts.client || 'claude-code');
1867
+ if (!['claude-code', 'claude'].includes(client)) throw new Error(`unsupported mcp install client: ${client}`);
1868
+ const plan = claudeCodeInstallPlan(file, opts);
1869
+ const dryRun = Boolean(opts['dry-run'] || opts.dryRun);
1870
+ const smoke = await runMcpSmoke({ ...opts, config: file, timeout: opts.timeout || 5000 });
1871
+ const removeArgs = ['mcp', 'remove', 'supercollab', '--scope', plan.scope];
1872
+
1873
+ if (dryRun) {
1874
+ return {
1875
+ ok: true,
1876
+ dry_run: true,
1877
+ client: 'claude-code',
1878
+ smoke,
1879
+ remove_command: shellCommand(plan.command, removeArgs),
1880
+ install_command: shellCommand(plan.command, plan.args),
1881
+ scope: plan.scope,
1882
+ cwd: plan.cwd,
1883
+ config: path.resolve(file),
1884
+ };
1885
+ }
1886
+
1887
+ const probe = runProcess(plan.command, ['--version'], { cwd: plan.cwd });
1888
+ if (!probe.ok) {
1889
+ throw new Error(`Claude Code CLI not found or not runnable as ${plan.command}. Install Claude Code first, or pass --claude /absolute/path/to/claude. ${probe.stderr || probe.error || ''}`.trim());
1890
+ }
1891
+
1892
+ runProcess(plan.command, removeArgs, { cwd: plan.cwd });
1893
+ const add = runProcess(plan.command, plan.args, { cwd: plan.cwd });
1894
+ if (!add.ok) {
1895
+ throw new Error(`claude mcp add failed: ${add.stderr || add.stdout || add.error || `exit ${add.status}`}`);
1896
+ }
1897
+ const list = runProcess(plan.command, ['mcp', 'list'], { cwd: plan.cwd });
1898
+ const get = runProcess(plan.command, ['mcp', 'get', 'supercollab'], { cwd: plan.cwd });
1899
+ return {
1900
+ ok: true,
1901
+ client: 'claude-code',
1902
+ scope: plan.scope,
1903
+ cwd: plan.cwd,
1904
+ config: path.resolve(file),
1905
+ smoke,
1906
+ install_command: shellCommand(plan.command, plan.args),
1907
+ claude_add: { stdout: add.stdout, stderr: add.stderr },
1908
+ claude_list: { ok: list.ok, stdout: list.stdout, stderr: list.stderr },
1909
+ claude_get: { ok: get.ok, stdout: get.stdout, stderr: get.stderr },
1910
+ next: 'Start Claude Code in this project, or run /mcp inside Claude Code and confirm supercollab is connected.',
1911
+ };
1912
+ }
1913
+
1691
1914
  async function promptSystemCheck(config, file, prompts) {
1692
1915
  const spin = prompts.spinner();
1693
1916
  spin.start('Checking this machine and warming the local BGE model');
@@ -1989,6 +2212,7 @@ async function promptMcpConfig(config, file, prompts) {
1989
2212
  const client = await prompts.select({
1990
2213
  message: 'MCP client config',
1991
2214
  options: [
2215
+ { value: 'claude-code', label: 'Claude Code', hint: 'one-command installer' },
1992
2216
  { value: 'codex', label: 'Codex', hint: 'TOML config snippet' },
1993
2217
  { value: 'claude', label: 'Claude', hint: 'JSON config snippet' },
1994
2218
  { value: 'manual', label: 'Manual', hint: 'stdio command' },
@@ -1999,6 +2223,42 @@ async function promptMcpConfig(config, file, prompts) {
1999
2223
  prompts.note(mcpConfigText(client, file), `${client} MCP config`);
2000
2224
  }
2001
2225
 
2226
+ async function promptInstallClaudeCode(config, file, prompts) {
2227
+ const cwd = await prompts.text({
2228
+ message: 'Claude Code project directory',
2229
+ defaultValue: process.cwd(),
2230
+ placeholder: process.cwd(),
2231
+ validate: (value) => fs.existsSync(path.resolve(String(value || ''))) ? undefined : 'Directory does not exist',
2232
+ });
2233
+ if (prompts.isCancel(cwd)) throw new Error('cancelled');
2234
+ const scope = await prompts.select({
2235
+ message: 'Claude Code MCP scope',
2236
+ options: [
2237
+ { value: 'local', label: 'Local project', hint: 'recommended; private to this project' },
2238
+ { value: 'user', label: 'User', hint: 'available across your projects' },
2239
+ ],
2240
+ });
2241
+ if (prompts.isCancel(scope)) throw new Error('cancelled');
2242
+ const spin = prompts.spinner();
2243
+ spin.start('Installing SuperCollab into Claude Code');
2244
+ try {
2245
+ const result = await installClaudeCodeMcp(config, file, { client: 'claude-code', cwd: String(cwd), scope });
2246
+ spin.stop('Claude Code MCP installed');
2247
+ prompts.note([
2248
+ `Scope: ${result.scope}`,
2249
+ `Project: ${result.cwd}`,
2250
+ `Smoke: ${result.smoke?.ok ? 'ok' : 'failed'}`,
2251
+ result.claude_list?.stdout || '',
2252
+ '',
2253
+ result.next,
2254
+ ].filter(Boolean).join('\n'), 'Claude Code');
2255
+ return result;
2256
+ } catch (err) {
2257
+ spin.stop('Claude Code install failed');
2258
+ throw err;
2259
+ }
2260
+ }
2261
+
2002
2262
  function sessionsFromResponse(data) {
2003
2263
  if (Array.isArray(data)) return data;
2004
2264
  if (Array.isArray(data?.sessions)) return data.sessions;
@@ -2147,6 +2407,7 @@ async function runSettingsMenu(config, file, prompts) {
2147
2407
  options: [
2148
2408
  { value: 'doctor', label: 'System check / install BGE model' },
2149
2409
  { value: 'account', label: 'Account and config status' },
2410
+ { value: 'install_claude_code', label: 'Install Claude Code MCP' },
2150
2411
  { value: 'mcp', label: 'Show MCP config' },
2151
2412
  { value: 'sessions', label: 'Manage sessions' },
2152
2413
  { value: 'server', label: 'Set server URL' },
@@ -2157,6 +2418,7 @@ async function runSettingsMenu(config, file, prompts) {
2157
2418
  try {
2158
2419
  if (action === 'doctor') await promptSystemCheck(config, file, prompts);
2159
2420
  if (action === 'account') await promptAccountStatus(config, file, prompts);
2421
+ if (action === 'install_claude_code') await promptInstallClaudeCode(config, file, prompts);
2160
2422
  if (action === 'mcp') await promptMcpConfig(config, file, prompts);
2161
2423
  if (action === 'sessions') await promptManageSessions(config, prompts);
2162
2424
  if (action === 'server') await promptSetServerUrl(config, file, prompts);
@@ -2233,6 +2495,7 @@ async function runSetupWizard(config, file, opts = {}) {
2233
2495
  const client = await prompts.select({
2234
2496
  message: 'MCP client config',
2235
2497
  options: [
2498
+ { value: 'claude-code', label: 'Claude Code', hint: 'one-command installer' },
2236
2499
  { value: 'codex', label: 'Codex', hint: 'TOML config snippet' },
2237
2500
  { value: 'claude', label: 'Claude', hint: 'JSON config snippet' },
2238
2501
  { value: 'manual', label: 'Manual', hint: 'stdio command' },
@@ -2321,6 +2584,8 @@ async function main() {
2321
2584
  if (sub === 'warmup') return console.log(JSON.stringify(await embeddingWarmup(), null, 2));
2322
2585
  }
2323
2586
  if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
2587
+ if (cmd === 'mcp' && sub === 'install') return console.log(JSON.stringify(await installClaudeCodeMcp(config, file, opts), null, 2));
2588
+ if (cmd === 'mcp' && sub === 'smoke') return console.log(JSON.stringify(await runMcpSmoke(opts), null, 2));
2324
2589
  if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
2325
2590
  throw new Error(`unknown command: ${positionals.join(' ')}`);
2326
2591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "SuperCollab CLI and MCP bridge for encrypted local-search agent group chat.",
5
5
  "type": "module",
6
6
  "bin": {