vibe-remote 2.1.6__py3-none-any.whl

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 (52) hide show
  1. config/__init__.py +37 -0
  2. config/paths.py +56 -0
  3. config/v2_compat.py +74 -0
  4. config/v2_config.py +206 -0
  5. config/v2_sessions.py +73 -0
  6. config/v2_settings.py +115 -0
  7. core/__init__.py +0 -0
  8. core/controller.py +736 -0
  9. core/handlers/__init__.py +13 -0
  10. core/handlers/command_handlers.py +342 -0
  11. core/handlers/message_handler.py +365 -0
  12. core/handlers/session_handler.py +233 -0
  13. core/handlers/settings_handler.py +362 -0
  14. modules/__init__.py +0 -0
  15. modules/agent_router.py +58 -0
  16. modules/agents/__init__.py +38 -0
  17. modules/agents/base.py +91 -0
  18. modules/agents/claude_agent.py +344 -0
  19. modules/agents/codex_agent.py +368 -0
  20. modules/agents/opencode_agent.py +2155 -0
  21. modules/agents/service.py +41 -0
  22. modules/agents/subagent_router.py +136 -0
  23. modules/claude_client.py +154 -0
  24. modules/im/__init__.py +63 -0
  25. modules/im/base.py +323 -0
  26. modules/im/factory.py +60 -0
  27. modules/im/formatters/__init__.py +4 -0
  28. modules/im/formatters/base_formatter.py +639 -0
  29. modules/im/formatters/slack_formatter.py +127 -0
  30. modules/im/slack.py +2091 -0
  31. modules/session_manager.py +138 -0
  32. modules/settings_manager.py +587 -0
  33. vibe/__init__.py +6 -0
  34. vibe/__main__.py +12 -0
  35. vibe/_version.py +34 -0
  36. vibe/api.py +412 -0
  37. vibe/cli.py +637 -0
  38. vibe/runtime.py +213 -0
  39. vibe/service_main.py +101 -0
  40. vibe/templates/slack_manifest.json +65 -0
  41. vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
  42. vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
  43. vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
  44. vibe/ui/dist/index.html +17 -0
  45. vibe/ui/dist/logo.png +0 -0
  46. vibe/ui/dist/vite.svg +1 -0
  47. vibe/ui_server.py +346 -0
  48. vibe_remote-2.1.6.dist-info/METADATA +295 -0
  49. vibe_remote-2.1.6.dist-info/RECORD +52 -0
  50. vibe_remote-2.1.6.dist-info/WHEEL +4 -0
  51. vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
  52. vibe_remote-2.1.6.dist-info/licenses/LICENSE +21 -0
vibe/cli.py ADDED
@@ -0,0 +1,637 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import shutil
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ from config import paths
13
+ from config.v2_config import (
14
+ AgentsConfig,
15
+ ClaudeConfig,
16
+ CodexConfig,
17
+ OpenCodeConfig,
18
+ RuntimeConfig,
19
+ SlackConfig,
20
+ V2Config,
21
+ )
22
+ from vibe import __version__, runtime
23
+
24
+
25
+ def _write_json(path, payload):
26
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
27
+
28
+
29
+ def _read_json(path):
30
+ if not path.exists():
31
+ return None
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def _pid_alive(pid):
36
+ try:
37
+ os.kill(pid, 0)
38
+ return True
39
+ except OSError:
40
+ return False
41
+
42
+
43
+ def _open_browser(url):
44
+ try:
45
+ subprocess.Popen(["open", url])
46
+ except Exception:
47
+ pass
48
+
49
+
50
+ def _default_config():
51
+ return V2Config(
52
+ mode="self_host",
53
+ version="v2",
54
+ slack=SlackConfig(bot_token="", app_token=""),
55
+ runtime=RuntimeConfig(default_cwd=str(Path.cwd())),
56
+ agents=AgentsConfig(
57
+ default_backend="opencode",
58
+ opencode=OpenCodeConfig(enabled=True, cli_path="opencode"),
59
+ claude=ClaudeConfig(enabled=True, cli_path="claude"),
60
+ codex=CodexConfig(enabled=False, cli_path="codex"),
61
+ ),
62
+ )
63
+
64
+
65
+ def _ensure_config():
66
+ config_path = paths.get_config_path()
67
+ if not config_path.exists():
68
+ default = _default_config()
69
+ default.save(config_path)
70
+ return V2Config.load(config_path)
71
+
72
+
73
+ def _write_status(state, detail=None):
74
+ payload = {
75
+ "state": state,
76
+ "detail": detail,
77
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
78
+ }
79
+ _write_json(paths.get_runtime_status_path(), payload)
80
+
81
+
82
+ def _spawn_background(
83
+ args,
84
+ pid_path,
85
+ stdout_name: str = "service_stdout.log",
86
+ stderr_name: str = "service_stderr.log",
87
+ ):
88
+ stdout_path = paths.get_runtime_dir() / stdout_name
89
+ stderr_path = paths.get_runtime_dir() / stderr_name
90
+ stdout_path.parent.mkdir(parents=True, exist_ok=True)
91
+ stdout = stdout_path.open("ab")
92
+ stderr = stderr_path.open("ab")
93
+ process = subprocess.Popen(
94
+ args,
95
+ stdout=stdout,
96
+ stderr=stderr,
97
+ start_new_session=True,
98
+ )
99
+ stdout.close()
100
+ stderr.close()
101
+ pid_path.write_text(str(process.pid), encoding="utf-8")
102
+ return process.pid
103
+
104
+
105
+ def _stop_process(pid_path):
106
+ if not pid_path.exists():
107
+ return False
108
+ pid = int(pid_path.read_text(encoding="utf-8").strip())
109
+ if not _pid_alive(pid):
110
+ pid_path.unlink(missing_ok=True)
111
+ return False
112
+ os.kill(pid, signal.SIGTERM)
113
+ pid_path.unlink(missing_ok=True)
114
+ return True
115
+
116
+
117
+ def _render_status():
118
+ status = _read_json(paths.get_runtime_status_path()) or {}
119
+ pid_path = paths.get_runtime_pid_path()
120
+ pid = pid_path.read_text(encoding="utf-8").strip() if pid_path.exists() else None
121
+ running = bool(pid and pid.isdigit() and _pid_alive(int(pid)))
122
+ status["running"] = running
123
+ status["pid"] = int(pid) if pid and pid.isdigit() else None
124
+ return json.dumps(status, indent=2)
125
+
126
+
127
+ def _doctor():
128
+ """Run diagnostic checks and return results in UI-compatible format.
129
+
130
+ Returns:
131
+ {
132
+ "groups": [{"name": "...", "items": [{"status": "pass|warn|fail", "message": "...", "action": "..."}]}],
133
+ "summary": {"pass": 0, "warn": 0, "fail": 0},
134
+ "ok": bool
135
+ }
136
+ """
137
+ groups = []
138
+ summary = {"pass": 0, "warn": 0, "fail": 0}
139
+
140
+ # Configuration Group
141
+ config_items = []
142
+ config_path = paths.get_config_path()
143
+
144
+ if config_path.exists():
145
+ config_items.append({
146
+ "status": "pass",
147
+ "message": f"Configuration file found: {config_path}",
148
+ })
149
+ summary["pass"] += 1
150
+ else:
151
+ config_items.append({
152
+ "status": "fail",
153
+ "message": "Configuration file not found",
154
+ "action": "Run 'vibe' to create initial configuration",
155
+ })
156
+ summary["fail"] += 1
157
+
158
+ config = None
159
+ try:
160
+ config = V2Config.load(config_path)
161
+ config_items.append({
162
+ "status": "pass",
163
+ "message": "Configuration loaded successfully",
164
+ })
165
+ summary["pass"] += 1
166
+ except Exception as exc:
167
+ config_items.append({
168
+ "status": "fail",
169
+ "message": f"Failed to load configuration: {exc}",
170
+ "action": "Check config.json syntax or delete and reconfigure",
171
+ })
172
+ summary["fail"] += 1
173
+
174
+ groups.append({"name": "Configuration", "items": config_items})
175
+
176
+ # Slack Group
177
+ slack_items = []
178
+ if config:
179
+ try:
180
+ config.slack.validate()
181
+ slack_items.append({
182
+ "status": "pass",
183
+ "message": "Slack token format is valid",
184
+ })
185
+ summary["pass"] += 1
186
+
187
+ # Check if tokens are actually set
188
+ if config.slack.bot_token:
189
+ slack_items.append({
190
+ "status": "pass",
191
+ "message": "Bot token is configured",
192
+ })
193
+ summary["pass"] += 1
194
+ else:
195
+ slack_items.append({
196
+ "status": "warn",
197
+ "message": "Bot token is not configured",
198
+ "action": "Add your Slack bot token in the setup wizard",
199
+ })
200
+ summary["warn"] += 1
201
+
202
+ if config.slack.app_token:
203
+ slack_items.append({
204
+ "status": "pass",
205
+ "message": "App token is configured (Socket Mode)",
206
+ })
207
+ summary["pass"] += 1
208
+ else:
209
+ slack_items.append({
210
+ "status": "warn",
211
+ "message": "App token is not configured",
212
+ "action": "Add your Slack app token for Socket Mode",
213
+ })
214
+ summary["warn"] += 1
215
+
216
+ except Exception as exc:
217
+ slack_items.append({
218
+ "status": "fail",
219
+ "message": f"Slack token validation failed: {exc}",
220
+ "action": "Check your Slack tokens in the setup wizard",
221
+ })
222
+ summary["fail"] += 1
223
+ else:
224
+ slack_items.append({
225
+ "status": "fail",
226
+ "message": "Cannot check Slack: configuration not loaded",
227
+ })
228
+ summary["fail"] += 1
229
+
230
+ groups.append({"name": "Slack", "items": slack_items})
231
+
232
+ # Agent Backends Group
233
+ agent_items = []
234
+ if config:
235
+ # OpenCode
236
+ if config.agents.opencode.enabled:
237
+ cli_path = config.agents.opencode.cli_path
238
+ import shutil
239
+ found_path = shutil.which(cli_path) if cli_path else None
240
+ if found_path:
241
+ agent_items.append({
242
+ "status": "pass",
243
+ "message": f"OpenCode CLI found: {found_path}",
244
+ })
245
+ summary["pass"] += 1
246
+ else:
247
+ agent_items.append({
248
+ "status": "warn",
249
+ "message": f"OpenCode CLI not found: {cli_path}",
250
+ "action": "Install OpenCode or update CLI path",
251
+ })
252
+ summary["warn"] += 1
253
+ else:
254
+ agent_items.append({
255
+ "status": "pass",
256
+ "message": "OpenCode: disabled",
257
+ })
258
+ summary["pass"] += 1
259
+
260
+ # Claude
261
+ if config.agents.claude.enabled:
262
+ cli_path = config.agents.claude.cli_path
263
+ import shutil
264
+ # Check preferred location first
265
+ preferred = Path.home() / ".claude" / "local" / "claude"
266
+ if preferred.exists() and os.access(preferred, os.X_OK):
267
+ found_path = str(preferred)
268
+ else:
269
+ found_path = shutil.which(cli_path) if cli_path else None
270
+
271
+ if found_path:
272
+ agent_items.append({
273
+ "status": "pass",
274
+ "message": f"Claude CLI found: {found_path}",
275
+ })
276
+ summary["pass"] += 1
277
+ else:
278
+ agent_items.append({
279
+ "status": "warn",
280
+ "message": f"Claude CLI not found: {cli_path}",
281
+ "action": "Install Claude Code or update CLI path",
282
+ })
283
+ summary["warn"] += 1
284
+ else:
285
+ agent_items.append({
286
+ "status": "pass",
287
+ "message": "Claude: disabled",
288
+ })
289
+ summary["pass"] += 1
290
+
291
+ # Codex
292
+ if config.agents.codex.enabled:
293
+ cli_path = config.agents.codex.cli_path
294
+ import shutil
295
+ found_path = shutil.which(cli_path) if cli_path else None
296
+ if found_path:
297
+ agent_items.append({
298
+ "status": "pass",
299
+ "message": f"Codex CLI found: {found_path}",
300
+ })
301
+ summary["pass"] += 1
302
+ else:
303
+ agent_items.append({
304
+ "status": "warn",
305
+ "message": f"Codex CLI not found: {cli_path}",
306
+ "action": "Install Codex or update CLI path",
307
+ })
308
+ summary["warn"] += 1
309
+ else:
310
+ agent_items.append({
311
+ "status": "pass",
312
+ "message": "Codex: disabled",
313
+ })
314
+ summary["pass"] += 1
315
+
316
+ # Default backend check
317
+ default_backend = config.agents.default_backend
318
+ agent_items.append({
319
+ "status": "pass",
320
+ "message": f"Default backend: {default_backend}",
321
+ })
322
+ summary["pass"] += 1
323
+ else:
324
+ agent_items.append({
325
+ "status": "fail",
326
+ "message": "Cannot check agents: configuration not loaded",
327
+ })
328
+ summary["fail"] += 1
329
+
330
+ groups.append({"name": "Agent Backends", "items": agent_items})
331
+
332
+ # Runtime Group
333
+ runtime_items = []
334
+ if config:
335
+ cwd = config.runtime.default_cwd
336
+ if cwd and os.path.isdir(cwd):
337
+ runtime_items.append({
338
+ "status": "pass",
339
+ "message": f"Working directory: {cwd}",
340
+ })
341
+ summary["pass"] += 1
342
+ else:
343
+ runtime_items.append({
344
+ "status": "warn",
345
+ "message": f"Working directory does not exist: {cwd}",
346
+ "action": "Update default_cwd in settings",
347
+ })
348
+ summary["warn"] += 1
349
+
350
+ runtime_items.append({
351
+ "status": "pass",
352
+ "message": f"Log level: {config.runtime.log_level}",
353
+ })
354
+ summary["pass"] += 1
355
+
356
+ # Check log file
357
+ log_path = paths.get_logs_dir() / "vibe_remote.log"
358
+ if log_path.exists():
359
+ runtime_items.append({
360
+ "status": "pass",
361
+ "message": f"Log file: {log_path}",
362
+ })
363
+ summary["pass"] += 1
364
+ else:
365
+ runtime_items.append({
366
+ "status": "pass",
367
+ "message": "Log file will be created on first run",
368
+ })
369
+ summary["pass"] += 1
370
+
371
+ groups.append({"name": "Runtime", "items": runtime_items})
372
+
373
+ # Calculate overall status
374
+ ok = summary["fail"] == 0
375
+
376
+ result = {
377
+ "groups": groups,
378
+ "summary": summary,
379
+ "ok": ok,
380
+ }
381
+
382
+ _write_json(paths.get_runtime_doctor_path(), result)
383
+ return result
384
+
385
+
386
+
387
+ def cmd_vibe():
388
+ paths.ensure_data_dirs()
389
+ config = _ensure_config()
390
+
391
+ # Always restart both processes
392
+ runtime.stop_service()
393
+ runtime.stop_ui()
394
+
395
+ if not config.slack.bot_token:
396
+ _write_status("setup", "missing Slack bot token")
397
+ else:
398
+ _write_status("starting")
399
+
400
+ service_pid = runtime.start_service()
401
+ ui_pid = runtime.start_ui(config.ui.setup_host, config.ui.setup_port)
402
+ runtime.write_status("running", "pid={}".format(service_pid), service_pid, ui_pid)
403
+
404
+ ui_url = "http://{}:{}".format(config.ui.setup_host, config.ui.setup_port)
405
+ if config.ui.open_browser:
406
+ _open_browser(ui_url)
407
+
408
+ return 0
409
+
410
+
411
+
412
+ def _stop_opencode_server():
413
+ """Terminate the OpenCode server if running."""
414
+ pid_file = paths.get_logs_dir() / "opencode_server.json"
415
+ if not pid_file.exists():
416
+ return False
417
+
418
+ try:
419
+ info = json.loads(pid_file.read_text(encoding="utf-8"))
420
+ except Exception:
421
+ return False
422
+
423
+ pid = info.get("pid") if isinstance(info, dict) else None
424
+ if not isinstance(pid, int) or not _pid_alive(pid):
425
+ pid_file.unlink(missing_ok=True)
426
+ return False
427
+
428
+ # Verify it's actually an opencode serve process
429
+ try:
430
+ import subprocess
431
+ result = subprocess.run(
432
+ ["ps", "-p", str(pid), "-o", "command="],
433
+ capture_output=True,
434
+ text=True,
435
+ )
436
+ cmd = result.stdout.strip()
437
+ if "opencode" not in cmd or "serve" not in cmd:
438
+ return False
439
+ except Exception:
440
+ return False
441
+
442
+ try:
443
+ os.kill(pid, signal.SIGTERM)
444
+ pid_file.unlink(missing_ok=True)
445
+ return True
446
+ except Exception:
447
+ return False
448
+
449
+
450
+ def cmd_stop():
451
+ runtime.stop_service()
452
+ runtime.stop_ui()
453
+
454
+ # Also terminate OpenCode server on full stop
455
+ if _stop_opencode_server():
456
+ print("OpenCode server stopped")
457
+
458
+ _write_status("stopped")
459
+ return 0
460
+
461
+
462
+ def cmd_status():
463
+ print(_render_status())
464
+ return 0
465
+
466
+
467
+ def cmd_doctor():
468
+ result = _doctor()
469
+
470
+ # Terminal-friendly output
471
+ print("\n Vibe Remote Diagnostics")
472
+ print(" " + "=" * 40)
473
+
474
+ for group in result.get("groups", []):
475
+ print(f"\n {group['name']}")
476
+ print(" " + "-" * 30)
477
+ for item in group.get("items", []):
478
+ status = item["status"]
479
+ if status == "pass":
480
+ icon = "\033[32m✓\033[0m" # Green checkmark
481
+ elif status == "warn":
482
+ icon = "\033[33m!\033[0m" # Yellow warning
483
+ else:
484
+ icon = "\033[31m✗\033[0m" # Red X
485
+
486
+ print(f" {icon} {item['message']}")
487
+ if item.get("action"):
488
+ print(f" → {item['action']}")
489
+
490
+ summary = result.get("summary", {})
491
+ print("\n " + "-" * 30)
492
+ print(f" \033[32m{summary.get('pass', 0)} passed\033[0m "
493
+ f"\033[33m{summary.get('warn', 0)} warnings\033[0m "
494
+ f"\033[31m{summary.get('fail', 0)} failed\033[0m")
495
+ print()
496
+
497
+ return 0 if result["ok"] else 1
498
+
499
+
500
+ def cmd_version():
501
+ """Show current version."""
502
+ print(f"vibe-remote {__version__}")
503
+ return 0
504
+
505
+
506
+ def get_latest_version() -> dict:
507
+ """Fetch latest version info from PyPI.
508
+
509
+ Returns:
510
+ {"current": str, "latest": str, "has_update": bool, "error": str|None}
511
+ """
512
+ current = __version__
513
+ result = {"current": current, "latest": None, "has_update": False, "error": None}
514
+
515
+ try:
516
+ url = "https://pypi.org/pypi/vibe-remote/json"
517
+ req = urllib.request.Request(url, headers={"User-Agent": "vibe-remote"})
518
+ with urllib.request.urlopen(req, timeout=10) as resp:
519
+ data = json.loads(resp.read().decode("utf-8"))
520
+ latest = data.get("info", {}).get("version", "")
521
+ result["latest"] = latest
522
+
523
+ # Simple version comparison (works for semver)
524
+ if latest and latest != current:
525
+ # Compare version tuples
526
+ try:
527
+ current_parts = [int(x) for x in current.split(".")[:3] if x.isdigit()]
528
+ latest_parts = [int(x) for x in latest.split(".")[:3] if x.isdigit()]
529
+ result["has_update"] = latest_parts > current_parts
530
+ except (ValueError, AttributeError):
531
+ # If version format is unusual, just check if different
532
+ result["has_update"] = latest != current
533
+ except Exception as e:
534
+ result["error"] = str(e)
535
+
536
+ return result
537
+
538
+
539
+ def cmd_check_update():
540
+ """Check for available updates."""
541
+ print(f"Current version: {__version__}")
542
+ print("Checking for updates...")
543
+
544
+ info = get_latest_version()
545
+
546
+ if info["error"]:
547
+ print(f"\033[33mFailed to check for updates: {info['error']}\033[0m")
548
+ return 1
549
+
550
+ if info["has_update"]:
551
+ print(f"\033[32mNew version available: {info['latest']}\033[0m")
552
+ print(f"\nRun '\033[1mvibe upgrade\033[0m' to update.")
553
+ else:
554
+ print("\033[32mYou are using the latest version.\033[0m")
555
+
556
+ return 0
557
+
558
+
559
+ def cmd_upgrade():
560
+ """Upgrade vibe-remote to the latest version."""
561
+ print(f"Current version: {__version__}")
562
+ print("Checking for updates...")
563
+
564
+ info = get_latest_version()
565
+
566
+ if info["error"]:
567
+ print(f"\033[33mFailed to check for updates: {info['error']}\033[0m")
568
+ print("Attempting upgrade anyway...")
569
+ elif not info["has_update"]:
570
+ print("\033[32mYou are already using the latest version.\033[0m")
571
+ return 0
572
+ else:
573
+ print(f"New version available: {info['latest']}")
574
+
575
+ print("\nUpgrading...")
576
+
577
+ # Determine upgrade method based on how vibe was installed
578
+ # Check if running from uv tool environment
579
+ exe_path = sys.executable
580
+ is_uv_tool = ".local/share/uv/tools/" in exe_path or "/uv/tools/" in exe_path
581
+
582
+ uv_path = shutil.which("uv")
583
+
584
+ if is_uv_tool and uv_path:
585
+ # Installed via uv tool, upgrade with uv
586
+ cmd = [uv_path, "tool", "upgrade", "vibe-remote"]
587
+ print(f"Using uv: {' '.join(cmd)}")
588
+ else:
589
+ # Installed via pip or other method, use current Python's pip
590
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "vibe-remote"]
591
+ print(f"Using pip: {' '.join(cmd)}")
592
+
593
+ try:
594
+ result = subprocess.run(cmd, capture_output=True, text=True)
595
+ if result.returncode == 0:
596
+ print("\033[32mUpgrade successful!\033[0m")
597
+ print("Please restart vibe to use the new version:")
598
+ print(" vibe stop && vibe")
599
+ return 0
600
+ else:
601
+ print(f"\033[31mUpgrade failed:\033[0m\n{result.stderr}")
602
+ return 1
603
+ except Exception as e:
604
+ print(f"\033[31mUpgrade failed: {e}\033[0m")
605
+ return 1
606
+
607
+
608
+ def build_parser():
609
+ parser = argparse.ArgumentParser(prog="vibe")
610
+ subparsers = parser.add_subparsers(dest="command")
611
+
612
+ subparsers.add_parser("stop", help="Stop all services")
613
+ subparsers.add_parser("status", help="Show service status")
614
+ subparsers.add_parser("doctor", help="Run diagnostics")
615
+ subparsers.add_parser("version", help="Show version")
616
+ subparsers.add_parser("check-update", help="Check for updates")
617
+ subparsers.add_parser("upgrade", help="Upgrade to latest version")
618
+ return parser
619
+
620
+
621
+ def main():
622
+ parser = build_parser()
623
+ args = parser.parse_args()
624
+
625
+ if args.command == "stop":
626
+ sys.exit(cmd_stop())
627
+ if args.command == "status":
628
+ sys.exit(cmd_status())
629
+ if args.command == "doctor":
630
+ sys.exit(cmd_doctor())
631
+ if args.command == "version":
632
+ sys.exit(cmd_version())
633
+ if args.command == "check-update":
634
+ sys.exit(cmd_check_update())
635
+ if args.command == "upgrade":
636
+ sys.exit(cmd_upgrade())
637
+ sys.exit(cmd_vibe())