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/api.py ADDED
@@ -0,0 +1,412 @@
1
+ import json
2
+ import asyncio
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ from config import paths
12
+ from config.v2_config import V2Config
13
+ from config.v2_settings import (
14
+ SettingsStore,
15
+ ChannelSettings,
16
+ RoutingSettings,
17
+ normalize_show_message_types,
18
+ )
19
+ from config.v2_sessions import SessionsStore
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _OPENCODE_OPTIONS_CACHE: dict[str, Optional[object]] = {
25
+ "data": None,
26
+ "updated_at": 0.0,
27
+ }
28
+ _OPENCODE_OPTIONS_TTL_SECONDS = 30.0
29
+
30
+
31
+ def load_config() -> V2Config:
32
+ return V2Config.load()
33
+
34
+
35
+ def save_config(payload: dict) -> V2Config:
36
+ config = V2Config.from_payload(payload)
37
+ config.save()
38
+ return config
39
+
40
+
41
+ def config_to_payload(config: V2Config) -> dict:
42
+ payload = {
43
+ "mode": config.mode,
44
+ "version": config.version,
45
+ "slack": {
46
+ **config.slack.__dict__,
47
+ "require_mention": config.slack.require_mention,
48
+ },
49
+ "runtime": {
50
+ "default_cwd": config.runtime.default_cwd,
51
+ "log_level": config.runtime.log_level,
52
+ },
53
+ "slack": {
54
+ **config.slack.__dict__,
55
+ "require_mention": config.slack.require_mention,
56
+ },
57
+ "agents": {
58
+ "default_backend": config.agents.default_backend,
59
+ "opencode": config.agents.opencode.__dict__,
60
+ "claude": config.agents.claude.__dict__,
61
+ "codex": config.agents.codex.__dict__,
62
+ },
63
+ "gateway": config.gateway.__dict__ if config.gateway else None,
64
+ "ui": config.ui.__dict__,
65
+ "ack_mode": config.ack_mode,
66
+ }
67
+ return payload
68
+
69
+
70
+ def get_settings() -> dict:
71
+ store = SettingsStore()
72
+ return _settings_to_payload(store)
73
+
74
+
75
+ def save_settings(payload: dict) -> dict:
76
+ store = SettingsStore()
77
+ channels = {}
78
+ for channel_id, channel_payload in (payload.get("channels") or {}).items():
79
+ routing_payload = channel_payload.get("routing") or {}
80
+ routing = RoutingSettings(
81
+ agent_backend=routing_payload.get("agent_backend"),
82
+ opencode_agent=routing_payload.get("opencode_agent"),
83
+ opencode_model=routing_payload.get("opencode_model"),
84
+ opencode_reasoning_effort=routing_payload.get("opencode_reasoning_effort"),
85
+ )
86
+ channels[channel_id] = ChannelSettings(
87
+ enabled=channel_payload.get("enabled", True),
88
+ show_message_types=normalize_show_message_types(
89
+ channel_payload.get("show_message_types")
90
+ ),
91
+ custom_cwd=channel_payload.get("custom_cwd"),
92
+ routing=routing,
93
+ require_mention=channel_payload.get("require_mention"),
94
+ )
95
+ store.settings.channels = channels
96
+ store.save()
97
+ return _settings_to_payload(store)
98
+
99
+
100
+ def init_sessions() -> None:
101
+ store = SessionsStore()
102
+ store.save()
103
+
104
+
105
+ def detect_cli(binary: str) -> dict:
106
+ if binary == "claude":
107
+ preferred = Path.home() / ".claude" / "local" / "claude"
108
+ if preferred.exists() and os.access(preferred, os.X_OK):
109
+ return {"found": True, "path": str(preferred)}
110
+ path = shutil.which(binary)
111
+ if not path:
112
+ return {"found": False, "path": None}
113
+ return {"found": True, "path": path}
114
+
115
+
116
+ def check_cli_exec(path: str) -> dict:
117
+ if not path:
118
+ return {"ok": False, "error": "path is empty"}
119
+ if not os.path.exists(path):
120
+ return {"ok": False, "error": "path does not exist"}
121
+ if not os.access(path, os.X_OK):
122
+ return {"ok": False, "error": "path is not executable"}
123
+ return {"ok": True}
124
+
125
+
126
+ def slack_auth_test(bot_token: str) -> dict:
127
+ try:
128
+ from slack_sdk.web import WebClient
129
+
130
+ client = WebClient(token=bot_token)
131
+ response = client.auth_test()
132
+ return {"ok": True, "response": response.data}
133
+ except Exception as exc:
134
+ return {"ok": False, "error": str(exc)}
135
+
136
+
137
+ def list_channels(bot_token: str) -> dict:
138
+ try:
139
+ from slack_sdk.web import WebClient
140
+
141
+ client = WebClient(token=bot_token)
142
+ channels = []
143
+ cursor = None
144
+ while True:
145
+ response = client.conversations_list(
146
+ types="public_channel,private_channel",
147
+ limit=200,
148
+ cursor=cursor,
149
+ )
150
+ for channel in response.get("channels", []):
151
+ channels.append({
152
+ "id": channel.get("id"),
153
+ "name": channel.get("name"),
154
+ "is_private": channel.get("is_private", False),
155
+ })
156
+ cursor = response.get("response_metadata", {}).get("next_cursor")
157
+ if not cursor:
158
+ break
159
+ return {"ok": True, "channels": channels}
160
+ except Exception as exc:
161
+ return {"ok": False, "error": str(exc)}
162
+
163
+
164
+ def opencode_options(cwd: str) -> dict:
165
+ try:
166
+ return asyncio.run(opencode_options_async(cwd))
167
+ except Exception as exc:
168
+ logger.warning("OpenCode options fetch failed: %s", exc, exc_info=True)
169
+ return {"ok": False, "error": str(exc)}
170
+
171
+
172
+ async def opencode_options_async(cwd: str) -> dict:
173
+ cache_data = _OPENCODE_OPTIONS_CACHE.get("data")
174
+ updated_at = _OPENCODE_OPTIONS_CACHE.get("updated_at")
175
+ updated_at_value = updated_at if isinstance(updated_at, float) else 0.0
176
+ cache_age = time.monotonic() - updated_at_value
177
+ if cache_data and cache_age < _OPENCODE_OPTIONS_TTL_SECONDS:
178
+ return {"ok": True, "data": cache_data, "cached": True}
179
+
180
+ try:
181
+ from config.v2_compat import to_app_config
182
+ from modules.agents.opencode_agent import (
183
+ OpenCodeServerManager,
184
+ build_reasoning_effort_options,
185
+ )
186
+
187
+ config = to_app_config(V2Config.load())
188
+ if not config.opencode:
189
+ return {"ok": False, "error": "opencode disabled"}
190
+ opencode_config = config.opencode
191
+ timeout_seconds = min(10.0, float(opencode_config.request_timeout_seconds or 10))
192
+
193
+ def _build_reasoning_options(
194
+ models: dict,
195
+ builder,
196
+ ) -> dict:
197
+ options: dict = {}
198
+ for provider in models.get("providers", []):
199
+ provider_id = (
200
+ provider.get("id")
201
+ or provider.get("provider_id")
202
+ or provider.get("name")
203
+ )
204
+ if not provider_id:
205
+ continue
206
+ model_ids = []
207
+ provider_models = provider.get("models", {})
208
+ if isinstance(provider_models, dict):
209
+ model_ids = list(provider_models.keys())
210
+ elif isinstance(provider_models, list):
211
+ model_ids = [
212
+ model.get("id")
213
+ for model in provider_models
214
+ if isinstance(model, dict) and model.get("id")
215
+ ]
216
+ for model_id in model_ids:
217
+ model_key = f"{provider_id}/{model_id}"
218
+ options[model_key] = builder(models, model_key)
219
+ return options
220
+
221
+ server = await OpenCodeServerManager.get_instance(
222
+ binary=opencode_config.binary,
223
+ port=opencode_config.port,
224
+ request_timeout_seconds=opencode_config.request_timeout_seconds,
225
+ )
226
+ await asyncio.wait_for(server.ensure_running(), timeout=timeout_seconds)
227
+ agents = await asyncio.wait_for(server.get_available_agents(cwd), timeout=timeout_seconds)
228
+ models = await asyncio.wait_for(server.get_available_models(cwd), timeout=timeout_seconds)
229
+ defaults = await asyncio.wait_for(server.get_default_config(cwd), timeout=timeout_seconds)
230
+ reasoning_options = _build_reasoning_options(models, build_reasoning_effort_options)
231
+ data = {
232
+ "agents": agents,
233
+ "models": models,
234
+ "defaults": defaults,
235
+ "reasoning_options": reasoning_options,
236
+ }
237
+ _OPENCODE_OPTIONS_CACHE["data"] = data
238
+ _OPENCODE_OPTIONS_CACHE["updated_at"] = time.monotonic()
239
+ return {"ok": True, "data": data}
240
+ except Exception as exc:
241
+ logger.warning("OpenCode options fetch failed: %s", exc, exc_info=True)
242
+ if cache_data:
243
+ return {"ok": True, "data": cache_data, "cached": True, "warning": str(exc)}
244
+ return {"ok": False, "error": str(exc)}
245
+
246
+
247
+ def _settings_to_payload(store: SettingsStore) -> dict:
248
+ payload = {"channels": {}}
249
+ for channel_id, settings in store.settings.channels.items():
250
+ payload["channels"][channel_id] = {
251
+ "enabled": settings.enabled,
252
+ "show_message_types": normalize_show_message_types(
253
+ settings.show_message_types
254
+ ),
255
+ "custom_cwd": settings.custom_cwd,
256
+ "require_mention": settings.require_mention,
257
+ "routing": {
258
+ "agent_backend": settings.routing.agent_backend,
259
+ "opencode_agent": settings.routing.opencode_agent,
260
+ "opencode_model": settings.routing.opencode_model,
261
+ "opencode_reasoning_effort": settings.routing.opencode_reasoning_effort,
262
+ },
263
+ }
264
+ return payload
265
+
266
+
267
+ def get_slack_manifest() -> dict:
268
+ """Get Slack App Manifest template for self-host mode.
269
+
270
+ Loads manifest from vibe/templates/slack_manifest.json.
271
+
272
+ Returns:
273
+ {"ok": True, "manifest": str, "manifest_compact": str} on success
274
+ {"ok": False, "error": str} on failure
275
+ """
276
+ import json
277
+ import importlib.resources
278
+
279
+ try:
280
+ manifest = None
281
+
282
+ # Try to load from package resources (installed via pip/uv)
283
+ try:
284
+ if hasattr(importlib.resources, 'files'):
285
+ package_files = importlib.resources.files('vibe')
286
+ template_path = package_files / 'templates' / 'slack_manifest.json'
287
+ if hasattr(template_path, 'read_text'):
288
+ manifest = json.loads(template_path.read_text(encoding='utf-8'))
289
+ except (TypeError, FileNotFoundError, AttributeError, json.JSONDecodeError):
290
+ pass
291
+
292
+ # Fallback: load from file system (development mode)
293
+ if manifest is None:
294
+ this_dir = Path(__file__).parent
295
+ template_file = this_dir / 'templates' / 'slack_manifest.json'
296
+ if template_file.exists():
297
+ manifest = json.loads(template_file.read_text(encoding='utf-8'))
298
+
299
+ if manifest is None:
300
+ return {"ok": False, "error": "Manifest template file not found"}
301
+
302
+ # Pretty JSON for display, compact JSON for URL
303
+ manifest_pretty = json.dumps(manifest, indent=2)
304
+ manifest_compact = json.dumps(manifest, separators=(',', ':'))
305
+ return {"ok": True, "manifest": manifest_pretty, "manifest_compact": manifest_compact}
306
+ except Exception as exc:
307
+ logger.error("Failed to load Slack manifest: %s", exc)
308
+ return {"ok": False, "error": str(exc)}
309
+
310
+
311
+ def get_version_info() -> dict:
312
+ """Get current version and check for updates.
313
+
314
+ Returns:
315
+ {
316
+ "current": str,
317
+ "latest": str | None,
318
+ "has_update": bool,
319
+ "error": str | None
320
+ }
321
+ """
322
+ import urllib.request
323
+ from vibe import __version__
324
+
325
+ current = __version__
326
+ result = {"current": current, "latest": None, "has_update": False, "error": None}
327
+
328
+ try:
329
+ url = "https://pypi.org/pypi/vibe-remote/json"
330
+ req = urllib.request.Request(url, headers={"User-Agent": "vibe-remote"})
331
+ with urllib.request.urlopen(req, timeout=10) as resp:
332
+ data = json.loads(resp.read().decode("utf-8"))
333
+ latest = data.get("info", {}).get("version", "")
334
+ result["latest"] = latest
335
+
336
+ # Simple version comparison (works for semver)
337
+ if latest and latest != current:
338
+ try:
339
+ current_parts = [int(x) for x in current.split(".")[:3] if x.isdigit()]
340
+ latest_parts = [int(x) for x in latest.split(".")[:3] if x.isdigit()]
341
+ result["has_update"] = latest_parts > current_parts
342
+ except (ValueError, AttributeError):
343
+ result["has_update"] = latest != current
344
+ except Exception as e:
345
+ result["error"] = str(e)
346
+
347
+ return result
348
+
349
+
350
+ def do_upgrade(auto_restart: bool = True) -> dict:
351
+ """Perform upgrade to latest version.
352
+
353
+ Args:
354
+ auto_restart: If True, restart vibe after successful upgrade
355
+
356
+ Returns:
357
+ {"ok": bool, "message": str, "output": str | None, "restarting": bool}
358
+ """
359
+ import sys
360
+
361
+ # Determine upgrade method based on how vibe was installed
362
+ # Check if running from uv tool environment
363
+ exe_path = sys.executable
364
+ is_uv_tool = ".local/share/uv/tools/" in exe_path or "/uv/tools/" in exe_path
365
+
366
+ uv_path = shutil.which("uv")
367
+
368
+ if is_uv_tool and uv_path:
369
+ # Installed via uv tool, upgrade with uv
370
+ cmd = [uv_path, "tool", "upgrade", "vibe-remote"]
371
+ else:
372
+ # Installed via pip or other method, use current Python's pip
373
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "vibe-remote"]
374
+
375
+ try:
376
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
377
+ if result.returncode == 0:
378
+ restarting = False
379
+ if auto_restart:
380
+ # Schedule restart in background after response is sent
381
+ # Use 'vibe' command which will restart both service and UI
382
+ vibe_path = shutil.which("vibe")
383
+ if vibe_path:
384
+ # Start restart process detached, with delay to allow response to be sent
385
+ restart_cmd = f"sleep 2 && {vibe_path}"
386
+ subprocess.Popen(
387
+ restart_cmd,
388
+ shell=True,
389
+ stdout=subprocess.DEVNULL,
390
+ stderr=subprocess.DEVNULL,
391
+ start_new_session=True,
392
+ )
393
+ restarting = True
394
+
395
+ return {
396
+ "ok": True,
397
+ "message": "Upgrade successful." + (" Restarting..." if restarting else " Please restart vibe."),
398
+ "output": result.stdout,
399
+ "restarting": restarting,
400
+ }
401
+ else:
402
+ return {
403
+ "ok": False,
404
+ "message": "Upgrade failed",
405
+ "output": result.stderr or result.stdout,
406
+ "restarting": False,
407
+ }
408
+ except subprocess.TimeoutExpired:
409
+ return {"ok": False, "message": "Upgrade timed out", "output": None, "restarting": False}
410
+ except Exception as e:
411
+ return {"ok": False, "message": str(e), "output": None, "restarting": False}
412
+