codetherapy 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.
@@ -0,0 +1,3 @@
1
+ """Code Therapy: talk to your Claude Code projects (by voice, from claude.ai) about how they're doing."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from codetherapy.cli import main
2
+
3
+ raise SystemExit(main())
codetherapy/cli.py ADDED
@@ -0,0 +1,494 @@
1
+ """Command-line interface: setup, serve, url, rotate, start and doctor."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import dataclasses
7
+ import os
8
+ import re
9
+ import shutil
10
+ import signal
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from mcp import Client
19
+
20
+ from codetherapy import __version__, clipboard, procutil, server, tunnel
21
+ from codetherapy import config as cfgmod
22
+ from codetherapy.config import Config, ConfigError
23
+
24
+ CLAUDE_DOCS = "https://docs.claude.com/en/docs/claude-code"
25
+ CONNECTORS_PAGE = "https://claude.ai/settings/connectors"
26
+ EXPECTED_TOOLS = 6
27
+ LIVE_TIMEOUT = 20
28
+ _PUBLIC_CHECK = True # tests turn this off: there is no real public endpoint
29
+ _children: list[subprocess.Popen] = []
30
+ _sleep = time.sleep # tests patch this, not the global time.sleep
31
+ SERVER_LABEL = "The Code Therapy server"
32
+ NGROK_LABEL = "ngrok"
33
+ STOP_SIGNALS = ("SIGTERM", "SIGHUP", "SIGBREAK") # treated like Ctrl+C by a blocking start
34
+
35
+
36
+ def connector_url(config: Config) -> str:
37
+ return f"https://{config.public_host}/mcp/{config.token}"
38
+
39
+
40
+ def _normalize_host(raw: str) -> str:
41
+ host = re.sub(r"^https?://", "", raw.strip(), flags=re.I)
42
+ host = re.split(r"[/?#]", host, maxsplit=1)[0]
43
+ return host.rpartition("@")[2].strip().lower()
44
+
45
+
46
+ def _valid_host(host: str) -> bool:
47
+ return bool(host) and "." in host and not host.endswith(".") and not re.search(r"\s", host)
48
+
49
+
50
+ def _err(message: str) -> None:
51
+ print(message, file=sys.stderr)
52
+
53
+
54
+ def _config_file(args: argparse.Namespace) -> Path:
55
+ return Path(args.config).expanduser() if args.config else cfgmod.config_path()
56
+
57
+
58
+ def _ngrok_install_hint(first_line: str, file=None) -> None:
59
+ for line in (first_line, " Windows: winget install ngrok.ngrok", " macOS: brew install ngrok",
60
+ " Other: https://ngrok.com/download"):
61
+ print(line, file=file)
62
+
63
+
64
+ def _check_prerequisites(use_ngrok: bool) -> bool:
65
+ if shutil.which("claude"):
66
+ print("✓ claude found on PATH")
67
+ else:
68
+ print(f"! claude not found on PATH: the ask_project tool won't work until Claude Code is installed ({CLAUDE_DOCS})")
69
+ if not use_ngrok:
70
+ return True
71
+ ngrok = tunnel.find_ngrok()
72
+ if not ngrok:
73
+ _ngrok_install_hint("! ngrok not found. Install it, then run `codetherapy setup` again:")
74
+ return False
75
+ print("✓ ngrok found")
76
+ if not tunnel.ngrok_has_authtoken(ngrok):
77
+ print("! ngrok has no authtoken. Copy yours from https://dashboard.ngrok.com/get-started/your-authtoken, then run:")
78
+ print(" ngrok config add-authtoken <token>")
79
+ return False
80
+ print("✓ ngrok authtoken configured")
81
+ return True
82
+
83
+
84
+ def _cmd_setup(args: argparse.Namespace) -> int:
85
+ path = _config_file(args)
86
+ values = cfgmod.read_values(path)
87
+ print(f"Code Therapy setup — config file: {path}")
88
+ if args.no_tunnel:
89
+ values["CODETHERAPY_TUNNEL"] = "none"
90
+ elif args.ngrok:
91
+ values["CODETHERAPY_TUNNEL"] = "ngrok"
92
+ use_ngrok = values.get("CODETHERAPY_TUNNEL", "ngrok").lower() != "none"
93
+ print("Tunnel: ngrok" if use_ngrok else "Tunnel: none (bring your own)")
94
+ if not _check_prerequisites(use_ngrok):
95
+ return 1
96
+
97
+ host = _normalize_host(args.public_host) if args.public_host else values.get("CODETHERAPY_PUBLIC_HOST", "")
98
+ if not host:
99
+ if not use_ngrok:
100
+ _err("With --no-tunnel, pass --public-host <your tunnel's hostname> (e.g. box.tailnet.ts.net).")
101
+ return 1
102
+ if args.yes:
103
+ _err("No public host known. Pass --public-host <your domain>.")
104
+ return 1
105
+ host = _normalize_host(input("Your ngrok static domain (dashboard.ngrok.com → Domains), e.g. name.ngrok-free.dev: "))
106
+ if not _valid_host(host):
107
+ _err(f"'{host}' is not a valid host name (expected something like name.ngrok-free.dev).")
108
+ return 1
109
+ values["CODETHERAPY_PUBLIC_HOST"] = host
110
+
111
+ if args.port is not None:
112
+ values["CODETHERAPY_PORT"] = str(args.port)
113
+ if args.projects_dir:
114
+ values["CODETHERAPY_PROJECTS_DIR"] = args.projects_dir
115
+ if args.rotate or not values.get("CODETHERAPY_TOKEN"):
116
+ values["CODETHERAPY_TOKEN"] = cfgmod.generate_token()
117
+ cfgmod.save_values(values, path)
118
+ print("Saved. Next: run `codetherapy start`.")
119
+ return 0
120
+
121
+
122
+ def _cmd_serve(args: argparse.Namespace) -> int:
123
+ server.run(cfgmod.load_config(_config_file(args)))
124
+ return 0
125
+
126
+
127
+ def _cmd_url(args: argparse.Namespace) -> int:
128
+ config = cfgmod.load_config(_config_file(args))
129
+ if not config.public_host:
130
+ _err("No public host configured. Run `codetherapy setup` first.")
131
+ return 1
132
+ _err("This URL is your connector's password. Don't share it.")
133
+ url = connector_url(config)
134
+ print(url)
135
+ if args.copy:
136
+ _err("copied" if clipboard.copy(url) else "(copy it from above)")
137
+ return 0
138
+
139
+
140
+ def _cmd_rotate(args: argparse.Namespace) -> int:
141
+ path = _config_file(args)
142
+ values = cfgmod.read_values(path)
143
+ values["CODETHERAPY_TOKEN"] = cfgmod.generate_token()
144
+ cfgmod.save_values(values, path)
145
+ print("New token saved. Restart `codetherapy start`, then update the connector URL on claude.ai "
146
+ "(run `codetherapy url`).")
147
+ return 0
148
+
149
+
150
+ def _port_arg(text: str) -> int:
151
+ try:
152
+ port = int(text)
153
+ except ValueError:
154
+ port = 0
155
+ if not 1 <= port <= 65535:
156
+ raise argparse.ArgumentTypeError(f"{text!r} is not a port number (1-65535)")
157
+ return port
158
+
159
+
160
+ def port_open(port: int, timeout: float = 0.3) -> bool:
161
+ try:
162
+ with socket.create_connection(("127.0.0.1", port), timeout):
163
+ return True
164
+ except (OSError, OverflowError, ValueError):
165
+ return False
166
+
167
+
168
+ def wait_port(port: int, proc: subprocess.Popen, timeout: float) -> bool:
169
+ deadline = time.monotonic() + timeout
170
+ while True:
171
+ if proc.poll() is not None:
172
+ return False
173
+ if port_open(port):
174
+ return True
175
+ if time.monotonic() >= deadline:
176
+ return False
177
+ time.sleep(0.25)
178
+
179
+
180
+ def stop_children() -> None:
181
+ """Kill every child tree. Ctrl+C is ignored meanwhile, so a second press can't leave one running."""
182
+ ignoring = threading.current_thread() is threading.main_thread()
183
+ previous = signal.signal(signal.SIGINT, signal.SIG_IGN) if ignoring else None
184
+ try:
185
+ while _children:
186
+ try:
187
+ procutil.kill_tree(_children[-1])
188
+ except KeyboardInterrupt: # raised before SIG_IGN took effect, or by a stop signal; retry
189
+ continue
190
+ _children.pop()
191
+ finally:
192
+ if ignoring:
193
+ signal.signal(signal.SIGINT, previous)
194
+
195
+
196
+ def _stop_signal(signum, frame):
197
+ raise KeyboardInterrupt
198
+
199
+
200
+ def _install_stop_handlers() -> dict:
201
+ """Make SIGTERM, SIGHUP and SIGBREAK behave like Ctrl+C, so `finally` cleanup runs."""
202
+ if threading.current_thread() is not threading.main_thread():
203
+ return {}
204
+ previous = {}
205
+ for name in STOP_SIGNALS:
206
+ sig = getattr(signal, name, None)
207
+ if sig is not None:
208
+ previous[sig] = signal.signal(sig, _stop_signal)
209
+ return previous
210
+
211
+
212
+ def _restore_handlers(previous: dict) -> None:
213
+ for sig, handler in previous.items():
214
+ signal.signal(sig, handler)
215
+
216
+
217
+ def _spawn(cmd: list[str], **kwargs) -> subprocess.Popen:
218
+ proc = subprocess.Popen(cmd, **kwargs, **procutil.popen_group_kwargs())
219
+ _children.append(proc)
220
+ return proc
221
+
222
+
223
+ def _launch(config: Config, ngrok: list[str] | None, config_file: Path | None,
224
+ use_tunnel: bool) -> list[tuple[str, subprocess.Popen]] | None:
225
+ """Start the server (and ngrok); return the labelled children, or None after reporting a failure."""
226
+ ngrok_cmd: list[str] | None = None
227
+ if use_tunnel: # check before starting anything, so a missing ngrok doesn't leave a server behind
228
+ if not config.public_host:
229
+ _err("No public host configured. Run `codetherapy setup` first.")
230
+ return None
231
+ if ngrok is None:
232
+ found = tunnel.find_ngrok()
233
+ if not found:
234
+ _ngrok_install_hint("ngrok not found. Install it, then run `codetherapy start` again:", file=sys.stderr)
235
+ return None
236
+ ngrok_cmd = tunnel.ngrok_command(found, config.port, config.public_host)
237
+ else:
238
+ ngrok_cmd = [*ngrok, *tunnel.ngrok_command("ngrok", config.port, config.public_host)[1:]]
239
+
240
+ serve_cmd = [sys.executable, "-m", "codetherapy", *(["--config", str(config_file)] if config_file else []), "serve"]
241
+ env = {**os.environ, "CODETHERAPY_PORT": str(config.port)}
242
+ if config.public_host: # so a --public-host override reaches the server's allowed-hosts list
243
+ env["CODETHERAPY_PUBLIC_HOST"] = config.public_host
244
+ server_proc = _spawn(serve_cmd, env=env)
245
+ procs = [(SERVER_LABEL, server_proc)]
246
+ if not wait_port(config.port, server_proc, 20):
247
+ _err("Code Therapy server didn't start (see messages above).")
248
+ return None
249
+
250
+ if ngrok_cmd:
251
+ ngrok_proc = _spawn(ngrok_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
252
+ text=True, encoding="utf-8", errors="replace")
253
+ procs.append((NGROK_LABEL, ngrok_proc))
254
+ ok, problem = tunnel.wait_online(ngrok_proc, 20)
255
+ if not ok:
256
+ _err(problem)
257
+ _report_ngrok_lines(_ngrok_lines(ngrok_proc, wait=1.0)) # e.g. "ERROR: unknown flag: --url"
258
+ return None
259
+
260
+ if use_tunnel or config.public_host:
261
+ url, note = connector_url(config), None
262
+ else:
263
+ url = f"http://127.0.0.1:{config.port}/mcp/{config.token}"
264
+ note = (f"Note: claude.ai needs a public HTTPS URL. Put a tunnel in front of port {config.port}, then run "
265
+ "`codetherapy setup --no-tunnel --public-host <its hostname>`.")
266
+ print("Code Therapy is running.")
267
+ print("Connector URL (keep it secret — it's the password):")
268
+ print(f" {url}")
269
+ print("(copied to clipboard)" if clipboard.copy(url) else "(copy it from above)")
270
+ if note:
271
+ print(note)
272
+ print(f"Add it at {CONNECTORS_PAGE} → Add custom connector. Press Ctrl+C to stop.", flush=True)
273
+ return procs
274
+
275
+
276
+ def _ngrok_lines(proc: subprocess.Popen, wait: float) -> list[str]:
277
+ """ngrok's recent non-blank output. Its args hold only the host, never the token."""
278
+ return [line.strip() for line in tunnel.recent_output(proc, wait=wait) if line.strip()]
279
+
280
+
281
+ def _report_ngrok_lines(lines: list[str]) -> None:
282
+ if lines:
283
+ _err("Last ngrok messages:\n " + "\n ".join(line[:300] for line in lines[-5:]))
284
+
285
+
286
+ def _report_stopped(label: str, proc: subprocess.Popen) -> None:
287
+ _err(f"{label} stopped (exit code {proc.returncode}).")
288
+ if label != NGROK_LABEL:
289
+ return
290
+ lines = _ngrok_lines(proc, wait=2.0)
291
+ hint = next((h for line in reversed(lines) if (h := tunnel.hint_for(line))), None)
292
+ if hint:
293
+ _err(hint)
294
+ else:
295
+ _report_ngrok_lines(lines)
296
+
297
+
298
+ def _supervise(procs: list[tuple[str, subprocess.Popen]]) -> int:
299
+ """Wait for Ctrl+C (or a stop signal) or a child exiting; then stop everything."""
300
+ try:
301
+ while True:
302
+ for label, proc in procs:
303
+ if proc.poll() is not None:
304
+ _report_stopped(label, proc)
305
+ return 1
306
+ _sleep(0.5)
307
+ except KeyboardInterrupt:
308
+ return 0
309
+ finally:
310
+ stop_children()
311
+ print("Stopped.", flush=True)
312
+
313
+
314
+ def start(config: Config, *, ngrok: list[str] | None = None, config_file: Path | None = None,
315
+ no_tunnel: bool = False, block: bool = True) -> int:
316
+ if port_open(config.port):
317
+ _err(f"port {config.port} is in use — is Code Therapy already running? Stop it or pass --port.")
318
+ return 1
319
+ use_tunnel = config.tunnel == "ngrok" and not no_tunnel
320
+ previous = _install_stop_handlers() if block else {}
321
+ try:
322
+ try:
323
+ procs = _launch(config, ngrok, config_file, use_tunnel)
324
+ except KeyboardInterrupt:
325
+ stop_children()
326
+ print("Stopped.", flush=True)
327
+ return 130
328
+ except BaseException:
329
+ stop_children()
330
+ raise
331
+ if procs is None:
332
+ stop_children()
333
+ return 1
334
+ return _supervise(procs) if block else 0
335
+ finally:
336
+ _restore_handlers(previous)
337
+
338
+
339
+ def _cmd_start(args: argparse.Namespace) -> int:
340
+ path = _config_file(args)
341
+ config = cfgmod.load_config(path)
342
+ if args.port is not None:
343
+ config = dataclasses.replace(config, port=args.port)
344
+ if args.public_host:
345
+ host = _normalize_host(args.public_host)
346
+ if not _valid_host(host):
347
+ _err(f"'{host}' is not a valid host name (expected something like name.ngrok-free.dev).")
348
+ return 1
349
+ config = dataclasses.replace(config, public_host=host)
350
+ return start(config, config_file=path, no_tunnel=args.no_tunnel)
351
+
352
+
353
+ async def _probe(url: str) -> tuple[int, str]:
354
+ async with Client(url) as client:
355
+ tools = await client.list_tools()
356
+ result = await client.call_tool("list_projects", {})
357
+ text = next((block.text for block in result.content if getattr(block, "text", None)), "")
358
+ return len(tools.tools), text
359
+
360
+
361
+ def _live_check(url: str, token: str) -> str | None:
362
+ """Run list_tools and list_projects against url; return a problem description, or None if it works."""
363
+ try:
364
+ count, text = asyncio.run(asyncio.wait_for(_probe(url), LIVE_TIMEOUT))
365
+ except TimeoutError:
366
+ return f"no answer within {LIVE_TIMEOUT}s"
367
+ except Exception as exc: # report, don't crash: doctor is for when things are broken
368
+ while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
369
+ exc = exc.exceptions[0]
370
+ detail = (str(exc) or type(exc).__name__).replace(token, "<token>")
371
+ return f"{type(exc).__name__}: {detail}"[:300]
372
+ if count != EXPECTED_TOOLS:
373
+ return f"expected {EXPECTED_TOOLS} tools, got {count}"
374
+ if not text:
375
+ return "list_projects returned no text"
376
+ return None
377
+
378
+
379
+ def doctor(config_file: Path | None, *, port: int | None = None) -> int:
380
+ failed = False
381
+
382
+ def check(ok: bool, good: str, bad: str, *, warn_only: bool = False) -> bool:
383
+ nonlocal failed
384
+ if ok:
385
+ print(f"✓ {good}")
386
+ else:
387
+ print(f"{'!' if warn_only else '✗'} {bad}")
388
+ failed = failed or not warn_only
389
+ return ok
390
+
391
+ path = config_file or cfgmod.config_path()
392
+ if not path.is_file():
393
+ check(False, "", f"no config file at {path}. Run `codetherapy setup`.")
394
+ return 1
395
+ try:
396
+ config = cfgmod.load_config(path)
397
+ except ConfigError as exc:
398
+ check(False, "", str(exc))
399
+ return 1
400
+ check(True, f"config file loads ({path})", "")
401
+ if port is not None: # match a `start --port N` run
402
+ config = dataclasses.replace(config, port=port)
403
+ check(bool(shutil.which("claude")), "claude found on PATH",
404
+ f"claude not found on PATH: the ask_project tool won't work until Claude Code is installed ({CLAUDE_DOCS})",
405
+ warn_only=True)
406
+ if config.tunnel == "ngrok":
407
+ check(bool(config.public_host), f"public host: {config.public_host}",
408
+ "no public host configured. Run `codetherapy setup`.")
409
+ ngrok = tunnel.find_ngrok()
410
+ if check(bool(ngrok), "ngrok found", "ngrok not found. Install it: winget install ngrok.ngrok (Windows), "
411
+ "brew install ngrok (macOS), or https://ngrok.com/download"):
412
+ check(tunnel.ngrok_has_authtoken(ngrok), "ngrok authtoken configured",
413
+ "ngrok has no authtoken. Run `ngrok config add-authtoken <token>` "
414
+ "(token: https://dashboard.ngrok.com/get-started/your-authtoken).")
415
+
416
+ if not port_open(config.port):
417
+ print("! not running — start it with `codetherapy start`")
418
+ return 1 if failed else 0
419
+ print(f"✓ something is listening on port {config.port}")
420
+
421
+ problem = _live_check(f"http://127.0.0.1:{config.port}/mcp/{config.token}", config.token)
422
+ check(problem is None, f"local endpoint answers ({EXPECTED_TOOLS} tools, list_projects works)",
423
+ f"local endpoint failed: {problem}. Is another program using port {config.port}, or is the server "
424
+ "running with an old token? Restart `codetherapy start`.")
425
+ if _PUBLIC_CHECK:
426
+ if not config.public_host:
427
+ print("! no public host configured, so the public endpoint can't be checked")
428
+ else:
429
+ problem = _live_check(f"https://{config.public_host}/mcp/{config.token}", config.token)
430
+ check(problem is None, f"public endpoint answers through {config.public_host}",
431
+ f"public endpoint failed: {problem}. Is the tunnel running and pointing at port {config.port}?")
432
+ return 1 if failed else 0
433
+
434
+
435
+ def _cmd_doctor(args: argparse.Namespace) -> int:
436
+ return doctor(_config_file(args), port=args.port)
437
+
438
+
439
+ def build_parser() -> argparse.ArgumentParser:
440
+ parser = argparse.ArgumentParser(prog="codetherapy", description="Talk to your Claude Code projects about how they're doing.")
441
+ parser.add_argument("--version", action="store_true", help="show the version and exit")
442
+ parser.add_argument("--config", metavar="PATH", help="config file to use (default: per-user location)")
443
+ sub = parser.add_subparsers(dest="command")
444
+
445
+ setup = sub.add_parser("setup", help="check prerequisites and write the config file")
446
+ setup.add_argument("--public-host", metavar="HOST", help="public hostname (e.g. your ngrok static domain)")
447
+ tun = setup.add_mutually_exclusive_group()
448
+ tun.add_argument("--no-tunnel", action="store_true", help="use your own tunnel; requires --public-host")
449
+ tun.add_argument("--ngrok", action="store_true", help="switch back to the built-in ngrok tunnel")
450
+ setup.add_argument("--port", type=_port_arg, metavar="N", help="local port (default 8765)")
451
+ setup.add_argument("--projects-dir", metavar="DIR", help="Claude Code projects directory")
452
+ setup.add_argument("--rotate", action="store_true", help="generate a new token")
453
+ setup.add_argument("--yes", action="store_true", help="never prompt")
454
+ setup.set_defaults(func=_cmd_setup)
455
+
456
+ sub.add_parser("serve", help="run the server only (no tunnel)").set_defaults(func=_cmd_serve)
457
+ url = sub.add_parser("url", help="print the connector URL")
458
+ url.add_argument("--copy", action="store_true", help="also copy it to the clipboard")
459
+ url.set_defaults(func=_cmd_url)
460
+ sub.add_parser("rotate", help="replace the token").set_defaults(func=_cmd_rotate)
461
+ start_p = sub.add_parser("start", help="run the server and tunnel")
462
+ start_p.add_argument("--no-tunnel", action="store_true", help="don't start ngrok (bring your own tunnel)")
463
+ start_p.add_argument("--port", type=_port_arg, metavar="N", help="local port for this run")
464
+ start_p.add_argument("--public-host", metavar="HOST", help="public hostname for this run")
465
+ start_p.set_defaults(func=_cmd_start)
466
+ doctor_p = sub.add_parser("doctor", help="diagnose problems")
467
+ doctor_p.add_argument("--port", type=_port_arg, metavar="N", help="check this port (if you ran `start --port N`)")
468
+ doctor_p.set_defaults(func=_cmd_doctor)
469
+ return parser
470
+
471
+
472
+ def main(argv: list[str] | None = None) -> int:
473
+ for stream in (sys.stdout, sys.stderr):
474
+ if hasattr(stream, "reconfigure"):
475
+ try:
476
+ stream.reconfigure(errors="replace")
477
+ except (ValueError, OSError):
478
+ pass
479
+ parser = build_parser()
480
+ args = parser.parse_args(argv)
481
+ if args.version:
482
+ print(f"codetherapy {__version__}")
483
+ return 0
484
+ if not getattr(args, "func", None):
485
+ parser.print_help()
486
+ return 2
487
+ try:
488
+ return args.func(args)
489
+ except ConfigError as exc:
490
+ _err(str(exc))
491
+ return 1
492
+ except (KeyboardInterrupt, EOFError) as exc:
493
+ _err("Aborted. Nothing was saved.")
494
+ return 130 if isinstance(exc, KeyboardInterrupt) else 1
@@ -0,0 +1,30 @@
1
+ """Best-effort clipboard copy; callers print the text anyway."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+
8
+
9
+ def _command() -> list[str] | None:
10
+ if sys.platform == "win32":
11
+ return ["clip"]
12
+ if sys.platform == "darwin":
13
+ tool = shutil.which("pbcopy")
14
+ return [tool] if tool else None
15
+ for name, args in (("wl-copy", []), ("xclip", ["-selection", "clipboard"]), ("xsel", ["--clipboard", "--input"])):
16
+ tool = shutil.which(name)
17
+ if tool:
18
+ return [tool, *args]
19
+ return None
20
+
21
+
22
+ def copy(text: str) -> bool:
23
+ cmd = _command()
24
+ if not cmd:
25
+ return False
26
+ try:
27
+ return subprocess.run(cmd, input=text, text=True, stdout=subprocess.DEVNULL,
28
+ stderr=subprocess.DEVNULL, timeout=5).returncode == 0
29
+ except (OSError, ValueError, subprocess.TimeoutExpired):
30
+ return False
codetherapy/config.py ADDED
@@ -0,0 +1,99 @@
1
+ """Settings from the per-user config file and CODETHERAPY_* environment variables."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import secrets
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ APP = "codetherapy"
12
+ PREFIX = "CODETHERAPY_"
13
+ MIN_TOKEN_LENGTH = 32
14
+ DEFAULT_PORT = 8765
15
+ TUNNELS = ("ngrok", "none")
16
+ KEY_ORDER = ("CODETHERAPY_TOKEN", "CODETHERAPY_PUBLIC_HOST", "CODETHERAPY_PORT",
17
+ "CODETHERAPY_TUNNEL", "CODETHERAPY_PROJECTS_DIR")
18
+
19
+
20
+ class ConfigError(ValueError):
21
+ """Configuration is missing or invalid; the message tells the user what to do."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Config:
26
+ token: str
27
+ port: int
28
+ projects_dir: Path
29
+ public_host: str
30
+ tunnel: str = "ngrok"
31
+
32
+
33
+ def config_path(environ: Mapping[str, str] | None = None, platform: str | None = None,
34
+ home: Path | None = None) -> Path:
35
+ environ = os.environ if environ is None else environ
36
+ platform = sys.platform if platform is None else platform
37
+ home = Path.home() if home is None else home
38
+ if environ.get("CODETHERAPY_CONFIG"):
39
+ return Path(environ["CODETHERAPY_CONFIG"]).expanduser()
40
+ if platform == "win32":
41
+ base = Path(environ["APPDATA"]) if environ.get("APPDATA") else home / "AppData" / "Roaming"
42
+ elif platform == "darwin":
43
+ base = home / "Library" / "Application Support"
44
+ else:
45
+ base = Path(environ["XDG_CONFIG_HOME"]) if environ.get("XDG_CONFIG_HOME") else home / ".config"
46
+ return base / APP / "config.env"
47
+
48
+
49
+ def read_values(path: Path) -> dict[str, str]:
50
+ values: dict[str, str] = {}
51
+ if not path.is_file():
52
+ return values
53
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
54
+ line = line.strip()
55
+ if not line or line.startswith("#") or "=" not in line:
56
+ continue
57
+ key, value = line.split("=", 1)
58
+ values[key.strip()] = value.strip().strip('"').strip("'")
59
+ return values
60
+
61
+
62
+ def save_values(values: dict[str, str], path: Path) -> None:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ ordered = [k for k in KEY_ORDER if k in values] + sorted(k for k in values if k not in KEY_ORDER)
65
+ body = "# Code Therapy settings. Keep this file private: the token is your connector's password.\n"
66
+ body += "".join(f"{key}={values[key]}\n" for key in ordered)
67
+ path.write_text(body, encoding="utf-8", newline="\n")
68
+ if sys.platform != "win32":
69
+ os.chmod(path, 0o600)
70
+
71
+
72
+ def generate_token() -> str:
73
+ return secrets.token_urlsafe(32)
74
+
75
+
76
+ def load_config(path: Path | None = None, environ: Mapping[str, str] | None = None) -> Config:
77
+ environ = os.environ if environ is None else environ
78
+ path = path or config_path(environ)
79
+ values = read_values(path)
80
+ values.update({k: v for k, v in environ.items() if k.startswith(PREFIX)})
81
+ token = values.get("CODETHERAPY_TOKEN", "")
82
+ if len(token) < MIN_TOKEN_LENGTH:
83
+ raise ConfigError(f"No valid token in {path}. Run `codetherapy setup` first.")
84
+ try:
85
+ port = int(values.get("CODETHERAPY_PORT", str(DEFAULT_PORT)))
86
+ except ValueError:
87
+ raise ConfigError(f"CODETHERAPY_PORT must be a number (in {path}). Run `codetherapy setup` to fix it.") from None
88
+ tunnel = values.get("CODETHERAPY_TUNNEL", "ngrok").lower()
89
+ if tunnel not in TUNNELS:
90
+ raise ConfigError(f"CODETHERAPY_TUNNEL must be one of {', '.join(TUNNELS)} (in {path}). "
91
+ "Run `codetherapy setup` to fix it.")
92
+ default_dir = Path.home() / ".claude" / "projects"
93
+ return Config(
94
+ token=token,
95
+ port=port,
96
+ projects_dir=Path(values.get("CODETHERAPY_PROJECTS_DIR", str(default_dir))).expanduser(),
97
+ public_host=values.get("CODETHERAPY_PUBLIC_HOST", ""),
98
+ tunnel=tunnel,
99
+ )