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.
- codetherapy/__init__.py +3 -0
- codetherapy/__main__.py +3 -0
- codetherapy/cli.py +494 -0
- codetherapy/clipboard.py +30 -0
- codetherapy/config.py +99 -0
- codetherapy/context.py +119 -0
- codetherapy/deep.py +93 -0
- codetherapy/notes.py +58 -0
- codetherapy/procutil.py +35 -0
- codetherapy/projects.py +131 -0
- codetherapy/search.py +163 -0
- codetherapy/server.py +163 -0
- codetherapy/sessions.py +101 -0
- codetherapy/textutil.py +11 -0
- codetherapy/tunnel.py +134 -0
- codetherapy-0.1.0.dist-info/METADATA +326 -0
- codetherapy-0.1.0.dist-info/RECORD +20 -0
- codetherapy-0.1.0.dist-info/WHEEL +4 -0
- codetherapy-0.1.0.dist-info/entry_points.txt +2 -0
- codetherapy-0.1.0.dist-info/licenses/LICENSE +21 -0
codetherapy/__init__.py
ADDED
codetherapy/__main__.py
ADDED
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
|
codetherapy/clipboard.py
ADDED
|
@@ -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
|
+
)
|