voxa-code 0.1.0__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.
server/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """`voxa` launcher: start the server + a public tunnel, then print a scannable QR.
2
+
3
+ Run it as `python -m server.cli` (or, once installed, just `voxa`). It:
4
+ 1. starts the FastAPI server (uvicorn),
5
+ 2. opens a Cloudflare quick tunnel and captures its public URL,
6
+ 3. prints a scannable QR + the pairing URL right in the terminal,
7
+ 4. cleans both up on Ctrl-C.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import os
14
+ import re
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+
20
+ _CF_URL = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
21
+
22
+ # The hosted Voxa Cloud (relay + metered Gemini + push). Non-secret defaults so
23
+ # `voxa` / `npx voxa` runs with ZERO configuration: no key, no token, no .env.
24
+ # The user's keys never touch the laptop; auth is by pairing with the phone app.
25
+ VOXA_DEFAULT_RELAY = "https://api.voxa.space"
26
+ VOXA_DEFAULT_LIVE_PROXY = "wss://api.voxa.space/live"
27
+
28
+
29
+ def _voxa_dir() -> str:
30
+ d = os.path.expanduser("~/.voxa")
31
+ os.makedirs(d, exist_ok=True)
32
+ return d
33
+
34
+
35
+ def _stable_secret(name: str, nbytes: int = 16) -> str:
36
+ """A value that's STABLE across `voxa` runs (persisted in ~/.voxa/<name>).
37
+
38
+ The pairing code and auth token must not change between launches, otherwise a
39
+ phone that paired once would be orphaned every time the laptop restarts."""
40
+ path = os.path.join(_voxa_dir(), name)
41
+ try:
42
+ with open(path) as f:
43
+ val = f.read().strip()
44
+ if val:
45
+ return val
46
+ except OSError:
47
+ pass
48
+ import secrets
49
+ val = secrets.token_hex(nbytes)
50
+ try:
51
+ with open(path, "w") as f:
52
+ f.write(val)
53
+ os.chmod(path, 0o600)
54
+ except OSError:
55
+ pass
56
+ return val
57
+
58
+
59
+ def _apply_zero_config_defaults() -> None:
60
+ """Fill env so the laptop client needs nothing typed. Metered mode means the
61
+ cloud holds the Gemini key; a random per-machine auth token gates only the
62
+ laptop's own loopback /ws (the phone reaches us through the relay by code).
63
+
64
+ The auth token and relay code are PERSISTED so a previously-paired phone keeps
65
+ working across laptop restarts (the saved QR stays valid)."""
66
+ env = os.environ
67
+ # Prefer a DIRECT Tailscale link (lowest latency: no relay hop) when it's
68
+ # available and the user hasn't pinned their own relay. Only fall back to the
69
+ # hosted relay default when Tailscale isn't an option (or VOXA_FORCE_RELAY is set).
70
+ # Note: switching transports changes the QR, so a phone paired on the relay must
71
+ # re-scan the Tailscale QR (and vice-versa).
72
+ force_relay = env.get("VOXA_FORCE_RELAY", "").strip().lower() not in ("", "0", "false", "no")
73
+ user_set_relay = bool(env.get("VOXA_RELAY_URL", "").strip())
74
+ if user_set_relay or force_relay or not _tailscale_available():
75
+ env.setdefault("VOXA_RELAY_URL", VOXA_DEFAULT_RELAY)
76
+ # else: leave VOXA_RELAY_URL unset so main() takes the Tailscale direct path.
77
+ env.setdefault("VOXA_LIVE_PROXY", VOXA_DEFAULT_LIVE_PROXY)
78
+ if not env.get("VOXA_AUTH_TOKEN", "").strip():
79
+ env["VOXA_AUTH_TOKEN"] = _stable_secret("auth_token")
80
+ if not env.get("VOXA_RELAY_CODE", "").strip():
81
+ # 16 bytes (128-bit) so the pairing code, which bridges a phone to this
82
+ # laptop, is not brute-forceable. (Persisted, so already-paired phones keep
83
+ # their existing code; only fresh installs get the longer one.)
84
+ env["VOXA_RELAY_CODE"] = _stable_secret("relay_code", nbytes=16)
85
+
86
+
87
+ def _print_qr(url: str) -> None:
88
+ import qrcode
89
+ qr = qrcode.QRCode(border=1)
90
+ qr.add_data(url)
91
+ qr.make(fit=True)
92
+ qr.print_ascii(invert=True)
93
+
94
+
95
+ def _banner(pairing_url: str) -> None:
96
+ line = "─" * 56
97
+ print(f"\n{line}")
98
+ print(" Voxa is live. Scan this with the Voxa app (or your phone camera):\n")
99
+ _print_qr(pairing_url)
100
+ print(f"\n {pairing_url}")
101
+ print("\n Phone browser works too, just open that URL.")
102
+ print(f" Press Ctrl-C to stop.\n{line}\n", flush=True)
103
+
104
+
105
+ def main() -> int:
106
+ from dotenv import load_dotenv
107
+ from server.config import load_config
108
+
109
+ load_dotenv() # a .env in the current directory
110
+ home_env = os.path.expanduser("~/.voxa/.env")
111
+ if os.path.exists(home_env):
112
+ load_dotenv(home_env) # optional overrides for self-hosters
113
+ # Zero-config: hosted relay + metered cloud Gemini + a random local token.
114
+ _apply_zero_config_defaults()
115
+ try:
116
+ cfg = load_config()
117
+ except ValueError as e:
118
+ print(
119
+ f"\nVoxa needs configuration: {e}\n"
120
+ "To point at your own server instead, set VOXA_RELAY_URL / VOXA_LIVE_PROXY\n"
121
+ "in ~/.voxa/.env. The default talks to the hosted Voxa Cloud.\n",
122
+ file=sys.stderr,
123
+ )
124
+ return 1
125
+ port = cfg.port
126
+ relay_url = os.environ.get("VOXA_RELAY_URL", "").strip()
127
+
128
+ if not relay_url and not _which("cloudflared"):
129
+ print(
130
+ "cloudflared is not installed. Install it with:\n"
131
+ " brew install cloudflared\n"
132
+ "(or set VOXA_RELAY_URL to your hosted relay).",
133
+ file=sys.stderr,
134
+ )
135
+ return 1
136
+
137
+ if _port_in_use(port):
138
+ print(
139
+ f"\nPort {port} is already in use, another Voxa may be running.\n"
140
+ f"Stop it first (e.g. `pkill -f 'server.cli'`) or set VOXA_PORT to a free port.\n",
141
+ file=sys.stderr,
142
+ )
143
+ return 1
144
+
145
+ env = {**os.environ, "VOXA_MODE": os.environ.get("VOXA_MODE", "attach")}
146
+ server = subprocess.Popen(
147
+ [sys.executable, "-m", "uvicorn", "server.app:create_app",
148
+ "--factory", "--host", "127.0.0.1", "--port", str(port)],
149
+ env=env,
150
+ )
151
+
152
+ active = {"tunnel": None}
153
+
154
+ def cleanup(*_):
155
+ for p in (active["tunnel"], server):
156
+ if p:
157
+ try:
158
+ p.terminate()
159
+ except Exception:
160
+ pass
161
+
162
+ atexit.register(cleanup)
163
+ for sig in (signal.SIGINT, signal.SIGTERM):
164
+ signal.signal(sig, lambda *a: (cleanup(), sys.exit(0)))
165
+
166
+ if not _wait_healthy(port):
167
+ print("server did not come up; not showing a QR.", file=sys.stderr)
168
+ cleanup()
169
+ return 1
170
+
171
+ # Install the GLOBAL Claude Code hook so EVERY Claude session on this machine
172
+ # rings the phone when it finishes or needs input (reliable + terminal-agnostic,
173
+ # unlike screen-scraping). Idempotent; set VOXA_INSTALL_HOOK=0 to skip.
174
+ if os.environ.get("VOXA_INSTALL_HOOK", "1").strip() not in ("0", "false", ""):
175
+ try:
176
+ from server.hooks import install_claude_hook, default_settings_path, hook_url
177
+ install_claude_hook(default_settings_path(),
178
+ hook_url("127.0.0.1", port, cfg.auth_token))
179
+ print(" ✓ Claude hook installed — calls you when any Claude session finishes.")
180
+ except Exception as e:
181
+ print(f" (could not install Claude hook: {e})", file=sys.stderr)
182
+
183
+ # Self-hosted relay mode: dial OUT to your cloud relay (no tunnel/Tailscale).
184
+ if relay_url:
185
+ import asyncio
186
+ import uuid
187
+ from server.relay_client import run_bridge
188
+ https = relay_url.rstrip("/")
189
+ host = https.split("://", 1)[-1]
190
+ ws = ("wss://" if https.startswith("https") else "ws://") + host
191
+ # Stable across restarts (persisted in ~/.voxa/relay_code) so a paired phone
192
+ # keeps working; falls back to a random code only if persistence failed.
193
+ code = os.environ.get("VOXA_RELAY_CODE", "").strip() or uuid.uuid4().hex[:10]
194
+ local_ws = f"ws://127.0.0.1:{port}/ws?token={cfg.auth_token}"
195
+ _banner(f"{https}/?code={code}&token={cfg.auth_token}")
196
+ try:
197
+ asyncio.run(run_bridge(ws, code, local_ws,
198
+ os.environ.get("VOXA_RELAY_TOKEN", "").strip()))
199
+ except KeyboardInterrupt:
200
+ pass
201
+ finally:
202
+ cleanup()
203
+ return 0
204
+
205
+ # Prefer Tailscale (stable, private, permanent URL — no flaky quick tunnels).
206
+ public_url = _tailscale_url(port)
207
+ if public_url:
208
+ print("Using your Tailscale network (stable, private link).", flush=True)
209
+ atexit.register(lambda: subprocess.run(
210
+ ["tailscale", "serve", "--https=443", "off"], capture_output=True))
211
+ if not _wait_public_healthy(public_url, tries=20):
212
+ print("Tailscale URL not reachable; falling back to a tunnel…", file=sys.stderr)
213
+ public_url = None
214
+
215
+ # Otherwise a cloudflare quick tunnel. These are per-instance flaky, so retry
216
+ # until one is actually reachable (and re-run if all fail).
217
+ if not public_url:
218
+ print("Opening secure tunnel…", flush=True)
219
+ for attempt in range(1, 4) if not public_url else []:
220
+ tunnel = subprocess.Popen(
221
+ # 127.0.0.1 (not localhost): localhost may resolve to IPv6 ::1 while
222
+ # uvicorn binds IPv4, which makes the tunnel 502.
223
+ ["cloudflared", "tunnel", "--url", f"http://127.0.0.1:{port}"],
224
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
225
+ )
226
+ active["tunnel"] = tunnel
227
+ url = _read_tunnel_url(tunnel, timeout=20)
228
+ if url:
229
+ threading.Thread(
230
+ target=lambda t=tunnel: [None for _ in t.stdout], daemon=True
231
+ ).start()
232
+ if _wait_public_healthy(url, tries=40):
233
+ public_url = url
234
+ break
235
+ try:
236
+ tunnel.terminate()
237
+ except Exception:
238
+ pass
239
+ if attempt < 3:
240
+ print(f" tunnel attempt {attempt} didn't connect, retrying…", flush=True)
241
+
242
+ if not public_url:
243
+ print(
244
+ "\nCouldn't get a working tunnel after 3 tries (cloudflare quick tunnels\n"
245
+ "can be flaky). Re-run `npx voxa`, or set up Tailscale for a permanent,\n"
246
+ "reliable link.\n",
247
+ file=sys.stderr,
248
+ )
249
+ cleanup()
250
+ return 1
251
+
252
+ pairing_url = f"{public_url}/?token={cfg.auth_token}"
253
+ _banner(pairing_url)
254
+
255
+ try:
256
+ server.wait()
257
+ except KeyboardInterrupt:
258
+ pass
259
+ finally:
260
+ cleanup()
261
+ return 0
262
+
263
+
264
+ def _which(name: str) -> bool:
265
+ from shutil import which
266
+ return which(name) is not None
267
+
268
+
269
+ def _tailscale_available() -> bool:
270
+ """True if Tailscale is installed and logged in (has a tailnet DNS name). A cheap
271
+ status-only check (no `serve`), used to decide whether to skip the relay default."""
272
+ if not _which("tailscale"):
273
+ return False
274
+ import json
275
+ try:
276
+ st = subprocess.run(["tailscale", "status", "--json"],
277
+ capture_output=True, text=True, timeout=5)
278
+ if st.returncode != 0:
279
+ return False
280
+ dns = (json.loads(st.stdout).get("Self") or {}).get("DNSName", "").rstrip(".")
281
+ return bool(dns)
282
+ except Exception:
283
+ return False
284
+
285
+
286
+ def _tailscale_url(port: int):
287
+ """If Tailscale is installed and logged in, serve the port over HTTPS on the
288
+ tailnet and return its stable https URL. Returns None to fall back to a tunnel."""
289
+ if not _which("tailscale"):
290
+ return None
291
+ import json
292
+ try:
293
+ st = subprocess.run(["tailscale", "status", "--json"],
294
+ capture_output=True, text=True, timeout=5)
295
+ if st.returncode != 0:
296
+ return None
297
+ dns = (json.loads(st.stdout).get("Self") or {}).get("DNSName", "").rstrip(".")
298
+ if not dns:
299
+ return None
300
+ r = subprocess.run(
301
+ ["tailscale", "serve", "--bg", "--https=443", f"http://127.0.0.1:{port}"],
302
+ capture_output=True, text=True, timeout=10,
303
+ )
304
+ if r.returncode != 0:
305
+ return None
306
+ return f"https://{dns}"
307
+ except Exception:
308
+ return None
309
+
310
+
311
+ def _read_tunnel_url(proc, timeout: int = 20):
312
+ """Read cloudflared output (in a thread) until its public URL appears, or give up."""
313
+ result = {"url": None}
314
+
315
+ def reader():
316
+ for line in proc.stdout:
317
+ m = _CF_URL.search(line)
318
+ if m:
319
+ result["url"] = m.group(0)
320
+ return
321
+
322
+ t = threading.Thread(target=reader, daemon=True)
323
+ t.start()
324
+ t.join(timeout)
325
+ return result["url"]
326
+
327
+
328
+ def _port_in_use(port: int) -> bool:
329
+ import socket
330
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
331
+ return s.connect_ex(("127.0.0.1", port)) == 0
332
+
333
+
334
+ def _wait_healthy(port: int, tries: int = 25) -> bool:
335
+ import time
336
+ import urllib.request
337
+ for _ in range(tries):
338
+ try:
339
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/healthz", timeout=1) as r:
340
+ if r.status == 200:
341
+ return True
342
+ except Exception:
343
+ pass
344
+ time.sleep(0.3)
345
+ return False
346
+
347
+
348
+ def _wait_public_healthy(public_url: str, tries: int = 70) -> bool:
349
+ """Poll the PUBLIC tunnel URL until it actually responds (the tunnel can take
350
+ several seconds to connect to Cloudflare's edge after printing its URL)."""
351
+ import time
352
+ import urllib.request
353
+ for _ in range(tries):
354
+ try:
355
+ with urllib.request.urlopen(f"{public_url}/healthz", timeout=3) as r:
356
+ if r.status == 200:
357
+ return True
358
+ except Exception:
359
+ pass
360
+ time.sleep(0.5)
361
+ return False
362
+
363
+
364
+ if __name__ == "__main__":
365
+ raise SystemExit(main())