@rmbk/compeek 0.2.3 → 0.2.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.
Files changed (2) hide show
  1. package/bin/compeek.mjs +431 -49
  2. package/package.json +12 -4
package/bin/compeek.mjs CHANGED
@@ -1,16 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // compeek CLI — zero dependencies, Node.js built-ins only
4
- // Usage: npx compeek [start|stop|status|logs|open]
4
+ // Usage: npx compeek [start|stop|status|logs|open|mcp]
5
5
 
6
6
  import { execSync, spawn } from 'node:child_process';
7
7
  import http from 'node:http';
8
8
  import crypto from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
9
11
 
10
12
  const IMAGE = 'ghcr.io/uburuntu/compeek:latest';
13
+ const VM_IMAGES = {
14
+ windows: 'dockurr/windows:latest',
15
+ macos: 'dockurr/macos:latest',
16
+ };
17
+ const SIDECAR_IMAGE = IMAGE;
11
18
  const CONTAINER_PREFIX = 'compeek-';
12
19
  const DASHBOARD_URL = 'https://compeek.rmbk.me';
13
20
  const HEALTH_TIMEOUT = 30_000;
21
+ const VM_HEALTH_TIMEOUT = 180_000; // VMs take longer to boot
14
22
  const HEALTH_INTERVAL = 1_000;
15
23
 
16
24
  // ── ANSI Colors ──────────────────────────────────────────
@@ -49,6 +57,38 @@ function hasDocker() {
49
57
  }
50
58
  }
51
59
 
60
+ function hasKvm() {
61
+ try {
62
+ run('ls /dev/kvm', { allowFail: false });
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function validateOsRequirements(os) {
70
+ if (os === 'linux') return;
71
+ const platform = process.platform;
72
+ const osLabel = os === 'windows' ? 'Windows' : 'macOS';
73
+
74
+ if (platform === 'darwin') {
75
+ console.error(`\n ${c.red}${osLabel} containers require a Linux host with KVM.${c.reset}`);
76
+ console.error(` They do not work on macOS Docker Desktop.\n`);
77
+ process.exit(1);
78
+ }
79
+ if (platform === 'win32' && os === 'macos') {
80
+ console.error(`\n ${c.red}macOS containers require a Linux host with KVM.${c.reset}`);
81
+ console.error(` They do not work on Windows Docker Desktop.\n`);
82
+ process.exit(1);
83
+ }
84
+ if (platform === 'linux' && !hasKvm()) {
85
+ console.error(`\n ${c.red}KVM is not available (/dev/kvm not found).${c.reset}`);
86
+ console.error(` ${osLabel} containers require hardware virtualization.`);
87
+ console.error(` Enable virtualization in BIOS and ensure the kvm kernel module is loaded.\n`);
88
+ process.exit(1);
89
+ }
90
+ }
91
+
52
92
  function listContainers() {
53
93
  const out = run(
54
94
  `docker ps -a --filter "name=^${CONTAINER_PREFIX}" --format "{{.Names}}\\t{{.Status}}\\t{{.Ports}}"`,
@@ -137,9 +177,61 @@ function waitForHealth(host, port, timeout) {
137
177
  });
138
178
  }
139
179
 
140
- function buildConnectionString(name, apiHost, apiPort, vncHost, vncPort, vncPassword) {
180
+ function waitForTunnelUrls(host, port, timeout) {
181
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
182
+ let frame = 0;
183
+ let spinner;
184
+
185
+ if (isColorSupported) {
186
+ spinner = setInterval(() => {
187
+ process.stdout.write(`\r ${c.cyan}${frames[frame]}${c.reset} Waiting for tunnel URLs...`);
188
+ frame = (frame + 1) % frames.length;
189
+ }, 80);
190
+ } else {
191
+ console.log(' Waiting for tunnel URLs...');
192
+ }
193
+
194
+ return new Promise((resolve) => {
195
+ const start = Date.now();
196
+ const check = () => {
197
+ const req = http.get(`http://${host}:${port}/api/info`, { timeout: 2000 }, (res) => {
198
+ let body = '';
199
+ res.on('data', d => body += d);
200
+ res.on('end', () => {
201
+ try {
202
+ const data = JSON.parse(body);
203
+ if (data.tunnel?.apiUrl && data.tunnel?.vncUrl) {
204
+ if (spinner) {
205
+ clearInterval(spinner);
206
+ process.stdout.write(`\r ${c.green}✓${c.reset} Tunnels ready \n`);
207
+ }
208
+ return resolve(data.tunnel);
209
+ }
210
+ } catch { /* retry */ }
211
+ retry();
212
+ });
213
+ });
214
+ req.on('error', retry);
215
+ req.on('timeout', () => { req.destroy(); retry(); });
216
+ };
217
+ const retry = () => {
218
+ if (Date.now() - start > timeout) {
219
+ if (spinner) {
220
+ clearInterval(spinner);
221
+ process.stdout.write(`\r ${c.yellow}!${c.reset} Tunnel timed out (local-only mode)\n`);
222
+ }
223
+ return resolve(null);
224
+ }
225
+ setTimeout(check, HEALTH_INTERVAL);
226
+ };
227
+ check();
228
+ });
229
+ }
230
+
231
+ function buildConnectionString(name, apiHost, apiPort, vncHost, vncPort, vncPassword, osType) {
141
232
  const config = { name, type: 'compeek', apiHost, apiPort, vncHost, vncPort };
142
233
  if (vncPassword) config.vncPassword = vncPassword;
234
+ if (osType && osType !== 'linux') config.osType = osType;
143
235
  return Buffer.from(JSON.stringify(config)).toString('base64');
144
236
  }
145
237
 
@@ -156,6 +248,33 @@ function openUrl(url) {
156
248
 
157
249
  // ── Commands ─────────────────────────────────────────────
158
250
 
251
+ function detectRunningContainer() {
252
+ const containers = listContainers().filter(c => c.status.startsWith('Up'));
253
+ if (containers.length === 0) return null;
254
+
255
+ const name = containers[0].name;
256
+ const inspect = run(`docker inspect --format '{{json .NetworkSettings.Ports}}' ${name}`, { allowFail: true });
257
+ if (!inspect) return null;
258
+
259
+ try {
260
+ const ports = JSON.parse(inspect);
261
+ const apiBinding = ports['3000/tcp']?.[0];
262
+ const apiPort = apiBinding ? parseInt(apiBinding.HostPort) : 3001;
263
+
264
+ // Extract VNC_PASSWORD from container env
265
+ const envStr = run(`docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${name}`, { allowFail: true });
266
+ let apiToken;
267
+ if (envStr) {
268
+ const match = envStr.match(/^VNC_PASSWORD=(.+)$/m);
269
+ if (match) apiToken = match[1];
270
+ }
271
+
272
+ return { name, apiPort, apiToken, containerUrl: `http://localhost:${apiPort}` };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
159
278
  async function cmdStart(args) {
160
279
  if (!hasDocker()) {
161
280
  console.error(`${c.red}Docker is not available.${c.reset} Install Docker first: https://docs.docker.com/get-docker/`);
@@ -163,79 +282,224 @@ async function cmdStart(args) {
163
282
  }
164
283
 
165
284
  const flags = parseFlags(args);
285
+ const os = flags.os || 'linux';
286
+ if (!['linux', 'windows', 'macos'].includes(os)) {
287
+ console.error(`${c.red}Invalid --os value: ${os}${c.reset}. Use: linux, windows, or macos`);
288
+ process.exit(1);
289
+ }
290
+
291
+ // Validate KVM requirements for Windows/macOS
292
+ if (os !== 'linux') {
293
+ validateOsRequirements(os);
294
+ }
295
+
166
296
  const name = flags.name || findNextName();
167
297
  const { apiPort: defaultApi, vncPort: defaultVnc } = findNextPorts();
168
298
  const apiPort = parseInt(flags['api-port']) || defaultApi;
169
299
  const vncPort = parseInt(flags['vnc-port']) || defaultVnc;
170
- const mode = flags.mode || 'full';
171
- const vncPassword = flags.password || crypto.randomBytes(6).toString('base64url').slice(0, 8);
172
- const sessionName = name.replace(CONTAINER_PREFIX, '').replace(/^(\d+)$/, 'Desktop $1');
300
+ const mode = os === 'linux' ? (flags.mode || 'full') : 'sidecar';
301
+ const vncPassword = flags.password || crypto.randomBytes(24).toString('base64url').slice(0, 24);
302
+ const osLabel = os === 'windows' ? 'Windows' : os === 'macos' ? 'macOS' : 'Linux';
303
+ const sessionName = flags.name
304
+ ? name.replace(CONTAINER_PREFIX, '')
305
+ : name.replace(CONTAINER_PREFIX, '').replace(/^(\d+)$/, `${osLabel} $1`);
306
+
307
+ // Tunnel provider: cloudflare by default for Linux, disabled for VMs in v1
308
+ const tunnelProvider = os !== 'linux' ? 'none'
309
+ : flags['no-tunnel'] ? 'none'
310
+ : typeof flags.tunnel === 'string' ? flags.tunnel
311
+ : flags.tunnel === true ? 'cloudflare'
312
+ : 'cloudflare';
173
313
 
174
314
  console.log('');
175
315
  console.log(` ${c.bold}${c.cyan}compeek${c.reset}`);
176
316
  console.log('');
177
317
 
178
- if (!flags['no-pull']) {
179
- console.log(` ${c.dim}Pulling image...${c.reset}`);
180
- try {
181
- run(`docker pull ${IMAGE}`, { stdio: 'inherit' });
182
- } catch {
183
- console.log(` ${c.yellow}Pull failed, using cached image.${c.reset}`);
318
+ if (os === 'linux') {
319
+ // ── Linux: single container (existing behavior) ──
320
+ if (!flags['no-pull']) {
321
+ console.log(` ${c.dim}Pulling image...${c.reset}`);
322
+ try {
323
+ run(`docker pull ${IMAGE}`, { stdio: 'inherit' });
324
+ } catch {
325
+ console.log(` ${c.yellow}Pull failed, using cached image.${c.reset}`);
326
+ }
327
+ console.log('');
184
328
  }
185
- console.log('');
186
- }
187
329
 
188
- const info = [
189
- `mode:${c.white}${mode}${c.reset}`,
190
- `api:${c.white}${apiPort}${c.reset}`,
191
- `vnc:${c.white}${vncPort}${c.reset}`,
192
- ];
193
- if (flags.persist) info.push(`${c.green}persist${c.reset}`);
194
- if (flags.tunnel) info.push(`${c.yellow}tunnel${c.reset}`);
195
-
196
- console.log(` ${c.cyan}▸${c.reset} Starting ${c.bold}${name}${c.reset} ${c.dim}${info.join(' · ')}${c.reset}`);
197
-
198
- // Remove existing container with same name if stopped
199
- run(`docker rm -f ${name}`, { allowFail: true });
200
-
201
- run([
202
- 'docker run -d',
203
- `--name ${name}`,
204
- `-p ${apiPort}:3000`,
205
- `-p ${vncPort}:6080`,
206
- `--shm-size=512m`,
207
- `-e DISPLAY=:1`,
208
- `-e DESKTOP_MODE=${mode}`,
209
- `-e COMPEEK_SESSION_NAME="${sessionName}"`,
210
- `-e VNC_PASSWORD="${vncPassword}"`,
211
- flags.tunnel ? '-e ENABLE_TUNNEL=true' : '',
212
- flags.persist ? `-v ${name}-data:/home/compeek/data` : '',
213
- `--security-opt seccomp=unconfined`,
214
- IMAGE,
215
- ].filter(Boolean).join(' '));
330
+ const info = [
331
+ `mode:${c.white}${mode}${c.reset}`,
332
+ `api:${c.white}${apiPort}${c.reset}`,
333
+ `vnc:${c.white}${vncPort}${c.reset}`,
334
+ ];
335
+ if (flags.persist) info.push(`${c.green}persist${c.reset}`);
336
+ if (tunnelProvider !== 'none') info.push(`${c.yellow}${tunnelProvider}${c.reset}`);
337
+
338
+ console.log(` ${c.cyan}▸${c.reset} Starting ${c.bold}${name}${c.reset} ${c.dim}${info.join(' · ')}${c.reset}`);
339
+
340
+ run(`docker rm -f ${name}`, { allowFail: true });
341
+
342
+ run([
343
+ 'docker run -d',
344
+ `--name ${name}`,
345
+ `-p ${apiPort}:3000`,
346
+ `-p ${vncPort}:6080`,
347
+ `--shm-size=512m`,
348
+ `-e DISPLAY=:1`,
349
+ `-e DESKTOP_MODE=${mode}`,
350
+ `-e COMPEEK_SESSION_NAME="${sessionName}"`,
351
+ `-e VNC_PASSWORD="${vncPassword}"`,
352
+ `-e TUNNEL_PROVIDER=${tunnelProvider}`,
353
+ flags.persist ? `-v ${name}-data:/home/compeek/data` : '',
354
+ `--security-opt seccomp=unconfined`,
355
+ IMAGE,
356
+ ].filter(Boolean).join(' '));
357
+ } else {
358
+ // ── Windows/macOS: VM + sidecar containers ──
359
+ const vmImage = VM_IMAGES[os];
360
+ const vmName = `${name}-vm`;
361
+ const netName = `${name}-net`;
362
+
363
+ if (!flags['no-pull']) {
364
+ console.log(` ${c.dim}Pulling images...${c.reset}`);
365
+ try {
366
+ run(`docker pull ${vmImage}`, { stdio: 'inherit' });
367
+ } catch {
368
+ console.log(` ${c.yellow}VM image pull failed, using cached.${c.reset}`);
369
+ }
370
+ try {
371
+ run(`docker pull ${SIDECAR_IMAGE}`, { stdio: 'inherit' });
372
+ } catch {
373
+ console.log(` ${c.yellow}Sidecar image pull failed, using cached.${c.reset}`);
374
+ }
375
+ console.log('');
376
+ }
216
377
 
378
+ const vmVersion = flags.version || (os === 'windows' ? '11' : '15');
379
+ const vmRam = flags.ram || '4G';
380
+ const vmCpus = flags.cpus || '2';
381
+ const vmDisk = flags.disk || '64G';
382
+
383
+ const info = [
384
+ `os:${c.white}${osLabel}${c.reset}`,
385
+ `ver:${c.white}${vmVersion}${c.reset}`,
386
+ `ram:${c.white}${vmRam}${c.reset}`,
387
+ `api:${c.white}${apiPort}${c.reset}`,
388
+ `vnc:${c.white}${vncPort}${c.reset}`,
389
+ ];
390
+ if (flags.persist) info.push(`${c.green}persist${c.reset}`);
391
+
392
+ console.log(` ${c.cyan}▸${c.reset} Starting ${c.bold}${name}${c.reset} ${c.dim}${info.join(' · ')}${c.reset}`);
393
+ console.log(` ${c.yellow}Note:${c.reset} ${osLabel} VM boot is slow. Tunneling not available for VMs.`);
394
+
395
+ // Clean up any existing containers/network
396
+ run(`docker rm -f ${name}`, { allowFail: true });
397
+ run(`docker rm -f ${vmName}`, { allowFail: true });
398
+ run(`docker network rm ${netName}`, { allowFail: true });
399
+
400
+ // Create dedicated network
401
+ run(`docker network create ${netName}`);
402
+
403
+ // Start dockur VM container
404
+ run([
405
+ 'docker run -d',
406
+ `--name ${vmName}`,
407
+ `--network ${netName}`,
408
+ `--device /dev/kvm`,
409
+ `-e VERSION=${vmVersion}`,
410
+ `-e RAM_SIZE=${vmRam}`,
411
+ `-e CPU_CORES=${vmCpus}`,
412
+ `-e DISK_SIZE=${vmDisk}`,
413
+ os === 'windows' ? '-e USERNAME=User -e PASSWORD=password' : '',
414
+ flags.persist ? `-v ${name}-data:/storage` : '',
415
+ `--cap-add NET_ADMIN`,
416
+ vmImage,
417
+ ].filter(Boolean).join(' '));
418
+
419
+ // Start compeek sidecar container
420
+ run([
421
+ 'docker run -d',
422
+ `--name ${name}`,
423
+ `--network ${netName}`,
424
+ `-p ${apiPort}:3000`,
425
+ `-p ${vncPort}:6080`,
426
+ `-e DESKTOP_MODE=sidecar`,
427
+ `-e SIDECAR_TARGET=${vmName}`,
428
+ `-e SIDECAR_VNC_PORT=5900`,
429
+ `-e SIDECAR_VIEWER_PORT=8006`,
430
+ `-e SIDECAR_OS=${os}`,
431
+ `-e COMPEEK_SESSION_NAME="${sessionName}"`,
432
+ `-e VNC_PASSWORD="${vncPassword}"`,
433
+ SIDECAR_IMAGE,
434
+ ].filter(Boolean).join(' '));
435
+ }
436
+
437
+ const healthTimeout = os === 'linux' ? HEALTH_TIMEOUT : VM_HEALTH_TIMEOUT;
217
438
  try {
218
- await waitForHealth('localhost', apiPort, HEALTH_TIMEOUT);
439
+ await waitForHealth('localhost', apiPort, healthTimeout);
219
440
  } catch {
220
441
  console.error(`\n ${c.red}Container did not start.${c.reset} Check logs: npx compeek logs`);
221
442
  process.exit(1);
222
443
  }
223
444
 
224
- const connStr = buildConnectionString(sessionName, 'localhost', apiPort, 'localhost', vncPort, vncPassword);
225
- const dashboardLink = `${DASHBOARD_URL}/#config=${connStr}`;
445
+ // Wait for tunnel URLs if tunneling is enabled (Linux only)
446
+ let tunnel = null;
447
+ if (tunnelProvider !== 'none' && mode !== 'headless') {
448
+ tunnel = await waitForTunnelUrls('localhost', apiPort, 30_000);
449
+ }
450
+
451
+ // Build connection strings
452
+ const localConnStr = buildConnectionString(sessionName, 'localhost', apiPort, 'localhost', vncPort, vncPassword, os);
453
+ let tunnelConnStr = null;
454
+ let dashboardLink;
455
+
456
+ if (tunnel) {
457
+ const apiUrl = new URL(tunnel.apiUrl);
458
+ const vncUrl = new URL(tunnel.vncUrl);
459
+ tunnelConnStr = buildConnectionString(
460
+ sessionName,
461
+ apiUrl.hostname, 443,
462
+ vncUrl.hostname, 443,
463
+ vncPassword,
464
+ os,
465
+ );
466
+ dashboardLink = `${DASHBOARD_URL}/#config=${tunnelConnStr}`;
467
+ } else {
468
+ dashboardLink = `${DASHBOARD_URL}/#config=${localConnStr}`;
469
+ }
226
470
 
227
471
  console.log('');
228
472
  console.log(` ${c.dim}──── Links ─────────────────────────────────────${c.reset}`);
229
473
  console.log('');
230
474
  console.log(` ${c.bold}Dashboard${c.reset} ${c.cyan}${dashboardLink}${c.reset}`);
231
- console.log(` ${c.dim}Tool API${c.reset} http://localhost:${apiPort}`);
475
+ if (tunnel) {
476
+ console.log(` ${c.dim}API${c.reset} ${tunnel.apiUrl}`);
477
+ console.log(` ${c.dim}noVNC${c.reset} ${tunnel.vncUrl}`);
478
+ }
479
+ console.log(` ${c.dim}Local API${c.reset} http://localhost:${apiPort}`);
232
480
  if (mode !== 'headless') {
233
- console.log(` ${c.dim}noVNC${c.reset} http://localhost:${vncPort}`);
481
+ console.log(` ${c.dim}Local VNC${c.reset} http://localhost:${vncPort}`);
234
482
  console.log(` ${c.dim}Password${c.reset} ${vncPassword}`);
235
483
  }
484
+ if (os !== 'linux') {
485
+ console.log('');
486
+ console.log(` ${c.yellow}Limitations:${c.reset} No bash/shell access in ${osLabel} VMs.`);
487
+ console.log(` ${c.dim}The agent uses mouse, keyboard, and screenshots only.${c.reset}`);
488
+ }
236
489
  console.log('');
237
490
  console.log(` ${c.dim}──── Connection string ──────────────────────────${c.reset}`);
238
- console.log(` ${c.dim}${connStr}${c.reset}`);
491
+ console.log(` ${c.dim}${tunnelConnStr || localConnStr}${c.reset}`);
492
+ console.log('');
493
+ console.log(` ${c.dim}──── MCP ───────────────────────────────────────${c.reset}`);
494
+ console.log('');
495
+ if (tunnel) {
496
+ console.log(` ${c.dim}Streamable HTTP${c.reset} ${tunnel.apiUrl}/mcp`);
497
+ }
498
+ console.log(` ${c.dim}Local MCP${c.reset} http://localhost:${apiPort}/mcp`);
499
+ console.log(` ${c.dim}stdio proxy${c.reset} npx @rmbk/compeek mcp`);
500
+ console.log('');
501
+ console.log(` ${c.dim}Claude Code config (~/.claude/settings.json):${c.reset}`);
502
+ console.log(` ${c.dim}{ "mcpServers": { "compeek": { "command": "npx", "args": ["-y", "@rmbk/compeek", "mcp"] } } }${c.reset}`);
239
503
  console.log('');
240
504
 
241
505
  if (flags.open) {
@@ -249,6 +513,9 @@ function cmdStop(args) {
249
513
  const name = target.startsWith(CONTAINER_PREFIX) ? target : `${CONTAINER_PREFIX}${target}`;
250
514
  console.log(` ${c.cyan}▸${c.reset} Stopping ${c.bold}${name}${c.reset}...`);
251
515
  run(`docker rm -f ${name}`, { allowFail: true, stdio: 'inherit' });
516
+ // Clean up companion VM container and network if they exist
517
+ run(`docker rm -f ${name}-vm`, { allowFail: true });
518
+ run(`docker network rm ${name}-net`, { allowFail: true });
252
519
  console.log(` ${c.green}✓${c.reset} Stopped`);
253
520
  } else {
254
521
  const containers = listContainers();
@@ -259,6 +526,9 @@ function cmdStop(args) {
259
526
  for (const ctr of containers) {
260
527
  console.log(` ${c.cyan}▸${c.reset} Stopping ${c.bold}${ctr.name}${c.reset}...`);
261
528
  run(`docker rm -f ${ctr.name}`, { allowFail: true });
529
+ // Clean up companion VM container and network
530
+ run(`docker rm -f ${ctr.name}-vm`, { allowFail: true });
531
+ run(`docker network rm ${ctr.name}-net`, { allowFail: true });
262
532
  }
263
533
  console.log(` ${c.green}✓${c.reset} Stopped ${containers.length} container(s)`);
264
534
  }
@@ -301,6 +571,94 @@ function cmdLogs(args) {
301
571
  child.on('exit', code => process.exit(code || 0));
302
572
  }
303
573
 
574
+ async function cmdMcp(args) {
575
+ const flags = parseFlags(args);
576
+ let containerUrl = flags['container-url'];
577
+ let apiToken = flags['api-token'];
578
+ let startedContainerName = null;
579
+
580
+ if (!containerUrl) {
581
+ // Auto-detect running container
582
+ const detected = detectRunningContainer();
583
+ if (detected) {
584
+ containerUrl = detected.containerUrl;
585
+ apiToken = apiToken || detected.apiToken;
586
+ process.stderr.write(`Using container ${detected.name} at ${containerUrl}\n`);
587
+ } else if (flags.start) {
588
+ // Auto-start a container
589
+ if (!hasDocker()) {
590
+ process.stderr.write('Docker is not available. Specify --container-url or install Docker.\n');
591
+ process.exit(1);
592
+ }
593
+
594
+ const name = flags.name || findNextName();
595
+ const { apiPort } = findNextPorts();
596
+ const vncPassword = flags.password || crypto.randomBytes(24).toString('base64url').slice(0, 24);
597
+ const mode = flags.mode || 'full';
598
+
599
+ run(`docker rm -f ${name}`, { allowFail: true });
600
+ run([
601
+ 'docker run -d',
602
+ `--name ${name}`,
603
+ `-p ${apiPort}:3000`,
604
+ `--shm-size=512m`,
605
+ `-e DISPLAY=:1`,
606
+ `-e DESKTOP_MODE=${mode}`,
607
+ `-e COMPEEK_SESSION_NAME="${name}"`,
608
+ `-e VNC_PASSWORD="${vncPassword}"`,
609
+ `-e TUNNEL_PROVIDER=none`,
610
+ flags.persist ? `-v ${name}-data:/home/compeek/data` : '',
611
+ `--security-opt seccomp=unconfined`,
612
+ IMAGE,
613
+ ].filter(Boolean).join(' '));
614
+
615
+ process.stderr.write(`Starting container ${name}...\n`);
616
+ try {
617
+ await waitForHealth('localhost', apiPort, HEALTH_TIMEOUT);
618
+ } catch {
619
+ process.stderr.write('Container did not start. Check logs: npx compeek logs\n');
620
+ process.exit(1);
621
+ }
622
+
623
+ containerUrl = `http://localhost:${apiPort}`;
624
+ apiToken = vncPassword;
625
+ startedContainerName = name;
626
+ process.stderr.write(`Container ${name} ready at ${containerUrl}\n`);
627
+ } else {
628
+ process.stderr.write('No running compeek container found.\n');
629
+ process.stderr.write('Start one with: npx @rmbk/compeek start\n');
630
+ process.stderr.write('Or use: npx @rmbk/compeek mcp --start\n');
631
+ process.exit(1);
632
+ }
633
+ }
634
+
635
+ // Resolve path to the compiled stdio entrypoint
636
+ const __dirname = dirname(fileURLToPath(import.meta.url));
637
+ const stdioPath = join(__dirname, '..', 'dist', 'mcp', 'stdio.js');
638
+
639
+ const mcpArgs = ['--container-url', containerUrl];
640
+ if (apiToken) mcpArgs.push('--api-token', apiToken);
641
+
642
+ const child = spawn('node', [stdioPath, ...mcpArgs], {
643
+ stdio: ['inherit', 'inherit', 'inherit'],
644
+ });
645
+
646
+ // Clean up auto-started container on exit
647
+ if (startedContainerName && !flags.persist) {
648
+ const cleanup = () => {
649
+ try {
650
+ run(`docker rm -f ${startedContainerName}`, { allowFail: true });
651
+ process.stderr.write(`Container ${startedContainerName} removed.\n`);
652
+ } catch { /* ignore */ }
653
+ };
654
+ process.on('exit', cleanup);
655
+ process.on('SIGINT', () => { child.kill('SIGINT'); });
656
+ process.on('SIGTERM', () => { child.kill('SIGTERM'); });
657
+ }
658
+
659
+ child.on('exit', code => process.exit(code || 0));
660
+ }
661
+
304
662
  function cmdOpen(args) {
305
663
  const target = args[0];
306
664
  let name;
@@ -348,8 +706,15 @@ function parseFlags(args) {
348
706
  const arg = args[i];
349
707
  if (arg.startsWith('--')) {
350
708
  const key = arg.slice(2);
351
- if (key === 'open' || key === 'no-pull' || key === 'persist' || key === 'tunnel') {
709
+ if (key === 'open' || key === 'no-pull' || key === 'persist' || key === 'no-tunnel' || key === 'start') {
352
710
  flags[key] = true;
711
+ } else if (key === 'tunnel') {
712
+ // --tunnel (default provider) or --tunnel cloudflare / --tunnel localtunnel
713
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
714
+ flags[key] = args[++i];
715
+ } else {
716
+ flags[key] = true;
717
+ }
353
718
  } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
354
719
  flags[key] = args[++i];
355
720
  }
@@ -378,6 +743,9 @@ switch (command) {
378
743
  case 'open':
379
744
  cmdOpen(rest);
380
745
  break;
746
+ case 'mcp':
747
+ cmdMcp(rest);
748
+ break;
381
749
  case '--help':
382
750
  case '-h':
383
751
  case 'help':
@@ -392,17 +760,31 @@ switch (command) {
392
760
  status ${c.dim}...........${c.reset} List running containers
393
761
  logs ${c.dim}[name]${c.reset} ${c.dim}....${c.reset} Follow container logs
394
762
  open ${c.dim}[name]${c.reset} ${c.dim}....${c.reset} Open dashboard in browser
763
+ mcp ${c.dim}..............${c.reset} Start MCP stdio server ${c.dim}(for Claude Code, Cursor, etc.)${c.reset}
395
764
 
396
765
  ${c.bold}Options${c.reset}
397
766
  --open ${c.dim}..........${c.reset} Open dashboard after start
767
+ --os ${c.dim}<os>${c.reset} ${c.dim}........${c.reset} linux ${c.dim}(default)${c.reset} ${c.dim}|${c.reset} windows ${c.dim}|${c.reset} macos
398
768
  --mode ${c.dim}<m>${c.reset} ${c.dim}......${c.reset} full ${c.dim}|${c.reset} browser ${c.dim}|${c.reset} minimal ${c.dim}|${c.reset} headless
399
769
  --persist ${c.dim}.......${c.reset} Mount volume for persistent data
400
770
  --password ${c.dim}<pw>${c.reset} ${c.dim}.${c.reset} Custom VNC password ${c.dim}(auto-generated if omitted)${c.reset}
401
- --tunnel ${c.dim}........${c.reset} Enable localtunnel for remote access
771
+ --no-tunnel ${c.dim}.....${c.reset} Disable tunneling ${c.dim}(local-only mode)${c.reset}
772
+ --tunnel ${c.dim}<p>${c.reset} ${c.dim}.....${c.reset} cloudflare ${c.dim}(default)${c.reset} ${c.dim}|${c.reset} localtunnel
402
773
  --no-pull ${c.dim}.......${c.reset} Skip pulling latest Docker image
403
774
  --name ${c.dim}<n>${c.reset} ${c.dim}......${c.reset} Custom container name
404
775
  --api-port ${c.dim}<p>${c.reset} ${c.dim}.${c.reset} Host port for tool API
405
776
  --vnc-port ${c.dim}<p>${c.reset} ${c.dim}.${c.reset} Host port for noVNC
777
+
778
+ ${c.bold}VM Options${c.reset} ${c.dim}(Windows/macOS only, requires KVM)${c.reset}
779
+ --version ${c.dim}<v>${c.reset} ${c.dim}..${c.reset} OS version ${c.dim}(Win: 11/10/8; macOS: 11-15)${c.reset}
780
+ --ram ${c.dim}<size>${c.reset} ${c.dim}....${c.reset} VM RAM ${c.dim}(default: 4G)${c.reset}
781
+ --cpus ${c.dim}<n>${c.reset} ${c.dim}......${c.reset} VM CPU cores ${c.dim}(default: 2)${c.reset}
782
+ --disk ${c.dim}<size>${c.reset} ${c.dim}...${c.reset} VM disk size ${c.dim}(default: 64G)${c.reset}
783
+
784
+ ${c.bold}MCP Options${c.reset}
785
+ --container-url ${c.dim}<u>${c.reset} Container API URL ${c.dim}(default: auto-detect)${c.reset}
786
+ --api-token ${c.dim}<t>${c.reset} ${c.dim}....${c.reset} Bearer token for container auth
787
+ --start ${c.dim}.........${c.reset} Auto-launch container if none running
406
788
  `);
407
789
  break;
408
790
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmbk/compeek",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "AI eyes and hands for any desktop application — a general-purpose computer use agent framework powered by Claude Opus 4.6",
5
5
  "license": "MIT",
6
6
  "author": "rmbk",
@@ -26,7 +26,8 @@
26
26
  "compeek": "bin/compeek.mjs"
27
27
  },
28
28
  "files": [
29
- "bin/"
29
+ "bin/",
30
+ "dist/"
30
31
  ],
31
32
  "scripts": {
32
33
  "dev:client": "vite",
@@ -37,15 +38,22 @@
37
38
  "docker:build": "docker build -f docker/Dockerfile -t compeek .",
38
39
  "docker:run": "docker run -p 3000:3000 -p 6080:6080 -p 5900:5900 --shm-size=512m -it compeek",
39
40
  "test": "vitest run",
40
- "test:watch": "vitest"
41
+ "test:watch": "vitest",
42
+ "test:e2e": "playwright test --project=ui",
43
+ "test:e2e:integration": "COMPEEK_E2E_INTEGRATION=1 playwright test --project=integration",
44
+ "test:e2e:all": "COMPEEK_E2E_INTEGRATION=1 playwright test",
45
+ "test:e2e:ui": "playwright test --project=ui --ui"
41
46
  },
42
47
  "dependencies": {
43
48
  "@anthropic-ai/sdk": "^0.74.0",
49
+ "@modelcontextprotocol/sdk": "^1.26.0",
44
50
  "cors": "^2.8.5",
45
51
  "express": "^4.21.0",
46
- "sharp": "^0.34.5"
52
+ "sharp": "^0.34.5",
53
+ "zod": "^4.3.6"
47
54
  },
48
55
  "devDependencies": {
56
+ "@playwright/test": "^1.58.2",
49
57
  "@testing-library/jest-dom": "^6.9.1",
50
58
  "@testing-library/react": "^16.3.2",
51
59
  "@types/cors": "^2.8.17",