letswork 0.1.3__tar.gz → 2.0.0__tar.gz
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.
- {letswork-0.1.3 → letswork-2.0.0}/PKG-INFO +1 -1
- letswork-2.0.0/letswork/cli.py +159 -0
- letswork-2.0.0/letswork/launcher.py +126 -0
- letswork-2.0.0/letswork/proxy.py +109 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/remote_client.py +46 -26
- {letswork-0.1.3 → letswork-2.0.0}/letswork/server.py +15 -4
- letswork-2.0.0/letswork/tui/chat_app.py +145 -0
- {letswork-0.1.3 → letswork-2.0.0}/pyproject.toml +3 -1
- letswork-0.1.3/letswork/cli.py +0 -162
- letswork-0.1.3/letswork/launcher.py +0 -55
- {letswork-0.1.3 → letswork-2.0.0}/.github/workflows/ci.yml +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/.github/workflows/publish.yml +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/.gitignore +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/README.md +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/docs/architecture.md +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/docs/spec.md +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/docs/tasks.md +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/__init__.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/approval.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/auth.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/events.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/filelock.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/__init__.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/app.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/approval_panel.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/chat.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/file_tree.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/file_viewer.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/letswork/tunnel.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/server.json +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/tests/__init__.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/tests/test_auth.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/tests/test_filelock.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/tests/test_server.py +0 -0
- {letswork-0.1.3 → letswork-2.0.0}/tests/test_tunnel.py +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import click
|
|
4
|
+
import threading
|
|
5
|
+
import letswork.server as server_module
|
|
6
|
+
from letswork.auth import generate_token
|
|
7
|
+
from letswork.tunnel import start_tunnel, stop_tunnel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def cli():
|
|
12
|
+
"""LetsWork — real-time collaborative coding via MCP."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@cli.command()
|
|
17
|
+
@click.option("--port", default=8000, type=int, help="Port for the MCP server")
|
|
18
|
+
def start(port):
|
|
19
|
+
"""Start a LetsWork collaboration session."""
|
|
20
|
+
from letswork.events import EventLog
|
|
21
|
+
from letswork.approval import ApprovalQueue
|
|
22
|
+
from letswork.launcher import launch_claude_code, launch_chat_window, register_guest_mcp
|
|
23
|
+
|
|
24
|
+
name = click.prompt("What's your name?")
|
|
25
|
+
|
|
26
|
+
project_root = os.getcwd()
|
|
27
|
+
server_module.project_root = project_root
|
|
28
|
+
|
|
29
|
+
host_token = generate_token()
|
|
30
|
+
guest_token = generate_token()
|
|
31
|
+
server_module.register_user(host_token, "host")
|
|
32
|
+
server_module.register_user(guest_token, "guest")
|
|
33
|
+
|
|
34
|
+
server_module.event_log = EventLog()
|
|
35
|
+
server_module.approval_queue = ApprovalQueue(project_root)
|
|
36
|
+
|
|
37
|
+
# Start tunnel
|
|
38
|
+
try:
|
|
39
|
+
url, tunnel_process = start_tunnel(port)
|
|
40
|
+
except RuntimeError as e:
|
|
41
|
+
click.echo(f"Error: {e}")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
mcp_url = f"{url}/mcp"
|
|
45
|
+
|
|
46
|
+
# Silence MCP and uvicorn loggers before server starts
|
|
47
|
+
import logging
|
|
48
|
+
for _name in ("uvicorn", "uvicorn.access", "uvicorn.error", "mcp", "mcp.server",
|
|
49
|
+
"mcp.server.streamable_http", "asyncio"):
|
|
50
|
+
logging.getLogger(_name).setLevel(logging.CRITICAL)
|
|
51
|
+
|
|
52
|
+
# Disable DNS rebinding protection so Cloudflare tunnel host header is accepted
|
|
53
|
+
if (hasattr(server_module.app.settings, "transport_security")
|
|
54
|
+
and server_module.app.settings.transport_security):
|
|
55
|
+
server_module.app.settings.transport_security.enable_dns_rebinding_protection = False
|
|
56
|
+
|
|
57
|
+
# Start MCP server in background thread — run uvicorn directly so we can
|
|
58
|
+
# pass log_config=None, which fully disables all uvicorn request logging.
|
|
59
|
+
def run_server():
|
|
60
|
+
import anyio
|
|
61
|
+
import uvicorn
|
|
62
|
+
|
|
63
|
+
async def _serve():
|
|
64
|
+
starlette_app = server_module.app.streamable_http_app()
|
|
65
|
+
config = uvicorn.Config(
|
|
66
|
+
starlette_app,
|
|
67
|
+
host="127.0.0.1",
|
|
68
|
+
port=port,
|
|
69
|
+
log_config=None,
|
|
70
|
+
log_level="critical",
|
|
71
|
+
)
|
|
72
|
+
await uvicorn.Server(config).serve()
|
|
73
|
+
|
|
74
|
+
anyio.run(_serve)
|
|
75
|
+
|
|
76
|
+
threading.Thread(target=run_server, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
# Wait until the server is actually responding (any HTTP response means it's up)
|
|
79
|
+
import urllib.request
|
|
80
|
+
import urllib.error
|
|
81
|
+
for _ in range(20):
|
|
82
|
+
try:
|
|
83
|
+
urllib.request.urlopen(f"http://127.0.0.1:{port}/mcp", timeout=1)
|
|
84
|
+
except urllib.error.HTTPError:
|
|
85
|
+
break # Got an HTTP response — server is up
|
|
86
|
+
except Exception:
|
|
87
|
+
time.sleep(0.5)
|
|
88
|
+
|
|
89
|
+
# Register letswork MCP for the host (needed for approvals)
|
|
90
|
+
register_guest_mcp(mcp_url, host_token)
|
|
91
|
+
|
|
92
|
+
# Open Claude Code in a new Terminal window
|
|
93
|
+
launch_claude_code(project_root, url, host_token)
|
|
94
|
+
|
|
95
|
+
# Open chat window in a separate terminal (uses local URL for reliability)
|
|
96
|
+
launch_chat_window(f"http://127.0.0.1:{port}/mcp", host_token, "host", name, project_root)
|
|
97
|
+
|
|
98
|
+
# Print session info — share guest_token with collaborator
|
|
99
|
+
click.echo("")
|
|
100
|
+
click.echo("╔══════════════════════════════════════════════════╗")
|
|
101
|
+
click.echo("║ 🤝 LetsWork Session Active ║")
|
|
102
|
+
click.echo("║ ║")
|
|
103
|
+
click.echo(f"║ MCP URL: {mcp_url}")
|
|
104
|
+
click.echo(f"║ Token: {guest_token}")
|
|
105
|
+
click.echo("║ ║")
|
|
106
|
+
click.echo("║ Share the URL + Token with your collaborator. ║")
|
|
107
|
+
click.echo("║ Press Ctrl+C to stop the session. ║")
|
|
108
|
+
click.echo("╚══════════════════════════════════════════════════╝")
|
|
109
|
+
click.echo("")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
time.sleep(1)
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
click.echo("\nShutting down...")
|
|
116
|
+
finally:
|
|
117
|
+
stop_tunnel(tunnel_process)
|
|
118
|
+
click.echo("Session ended.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cli.command()
|
|
122
|
+
@click.argument("url")
|
|
123
|
+
@click.option("--token", prompt="Enter session token", help="Secret token from the host")
|
|
124
|
+
def join(url, token):
|
|
125
|
+
"""Join a LetsWork session as a guest."""
|
|
126
|
+
from letswork.launcher import register_guest_mcp, launch_guest_claude_code, launch_chat_window
|
|
127
|
+
|
|
128
|
+
name = click.prompt("What's your name?")
|
|
129
|
+
|
|
130
|
+
if not url.endswith("/mcp"):
|
|
131
|
+
url = url.rstrip("/") + "/mcp"
|
|
132
|
+
|
|
133
|
+
click.echo(f"\nConnecting to {url} as {name}...")
|
|
134
|
+
|
|
135
|
+
# Register MCP with Claude Code (via stdio proxy — reliable over Cloudflare)
|
|
136
|
+
register_guest_mcp(url, token)
|
|
137
|
+
|
|
138
|
+
# Open Claude Code in a new Terminal window (banner shows token for reference)
|
|
139
|
+
launch_guest_claude_code(os.getcwd(), url, token)
|
|
140
|
+
|
|
141
|
+
# Open chat window in a separate terminal
|
|
142
|
+
launch_chat_window(url, token, "guest", name, os.getcwd())
|
|
143
|
+
|
|
144
|
+
click.echo("✅ Claude Code is opening with LetsWork MCP connected.")
|
|
145
|
+
click.echo(" You can close this terminal.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
def stop():
|
|
150
|
+
click.echo("Use Ctrl+C in the terminal running 'letswork start' to stop.")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
def status():
|
|
155
|
+
click.echo("Use the get_status MCP tool inside Claude Code to check session status.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
cli()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _open_terminal(command: str, project_root: str) -> bool:
|
|
8
|
+
"""
|
|
9
|
+
Open a new terminal window running `command` in `project_root`.
|
|
10
|
+
Returns True if a terminal was launched, False if we fell back to printing.
|
|
11
|
+
"""
|
|
12
|
+
if sys.platform == "darwin":
|
|
13
|
+
script = f'tell application "Terminal" to do script "cd {shlex.quote(project_root)} && {command}"'
|
|
14
|
+
subprocess.Popen(["osascript", "-e", script])
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
if sys.platform.startswith("linux"):
|
|
18
|
+
# Try common Linux terminal emulators in order of preference
|
|
19
|
+
for term, args in [
|
|
20
|
+
("gnome-terminal", ["--", "bash", "-c", f"cd {project_root} && {command}; exec bash"]),
|
|
21
|
+
("xfce4-terminal", ["-e", f"bash -c 'cd {project_root} && {command}; exec bash'"]),
|
|
22
|
+
("konsole", ["--noclose", "-e", "bash", "-c", f"cd {project_root} && {command}"]),
|
|
23
|
+
("xterm", ["-e", f"bash -c 'cd {project_root} && {command}; exec bash'"]),
|
|
24
|
+
]:
|
|
25
|
+
if shutil.which(term):
|
|
26
|
+
subprocess.Popen([term] + args)
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
# Windows Terminal (wt) or fallback to cmd
|
|
32
|
+
if shutil.which("wt"):
|
|
33
|
+
subprocess.Popen(["wt", "new-tab", "--title", "LetsWork",
|
|
34
|
+
"cmd", "/k", f"cd /d {project_root} && {command}"])
|
|
35
|
+
else:
|
|
36
|
+
subprocess.Popen(["cmd", "/c", "start", "cmd", "/k",
|
|
37
|
+
f"cd /d {project_root} && {command}"])
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _make_banner(mcp_url: str, token: str) -> str:
|
|
44
|
+
"""Shell command that prints the LetsWork banner then launches claude."""
|
|
45
|
+
return (
|
|
46
|
+
f"clear && "
|
|
47
|
+
f"echo '╔══════════════════════════════════════════════════╗' && "
|
|
48
|
+
f"echo '║ Claude Code — Connected to LetsWork ║' && "
|
|
49
|
+
f"echo '║ ║' && "
|
|
50
|
+
f"echo '║ MCP URL: {mcp_url}' && "
|
|
51
|
+
f"echo '║ Token: {token}' && "
|
|
52
|
+
f"echo '║ ║' && "
|
|
53
|
+
f"echo '║ Try: list_files, read_file, write_file ║' && "
|
|
54
|
+
f"echo '╚══════════════════════════════════════════════════╝' && "
|
|
55
|
+
f"echo '' && claude"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def launch_claude_code(project_root: str, tunnel_url: str, token: str) -> None:
|
|
60
|
+
"""Open a new terminal window with Claude Code for the host."""
|
|
61
|
+
if not shutil.which("claude"):
|
|
62
|
+
print("⚠️ Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
mcp_url = f"{tunnel_url}/mcp"
|
|
66
|
+
launched = _open_terminal(_make_banner(mcp_url, token), project_root)
|
|
67
|
+
if not launched:
|
|
68
|
+
print("Open a new terminal, cd to your project, and run: claude")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_guest_mcp(url: str, token: str) -> None:
|
|
72
|
+
"""Register LetsWork as a stdio proxy MCP with Claude Code (blocking).
|
|
73
|
+
|
|
74
|
+
Uses stdio transport (always works) rather than HTTP transport (unreliable
|
|
75
|
+
over Cloudflare SSE). The proxy forwards tool calls to the host via HTTP.
|
|
76
|
+
"""
|
|
77
|
+
proxy_path = shutil.which("letswork-proxy")
|
|
78
|
+
if not proxy_path:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Remove any stale entry from all scopes
|
|
82
|
+
for scope in ("user", "local", "project"):
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["claude", "mcp", "remove", "letswork", "--scope", scope],
|
|
85
|
+
check=False, capture_output=True, text=True,
|
|
86
|
+
)
|
|
87
|
+
subprocess.run(
|
|
88
|
+
[
|
|
89
|
+
"claude", "mcp", "add", "letswork",
|
|
90
|
+
"--scope", "user",
|
|
91
|
+
"--", proxy_path, "--url", url, "--token", token,
|
|
92
|
+
],
|
|
93
|
+
check=False, capture_output=True, text=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def launch_guest_claude_code(project_root: str, url: str, token: str) -> None:
|
|
98
|
+
"""Open a new terminal window with Claude Code for the guest."""
|
|
99
|
+
if not shutil.which("claude"):
|
|
100
|
+
print("⚠️ Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
launched = _open_terminal(_make_banner(url, token), project_root)
|
|
104
|
+
if not launched:
|
|
105
|
+
print("Open a new terminal, cd to your project, and run: claude")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def launch_chat_window(url: str, token: str, role: str, name: str, project_root: str) -> None:
|
|
109
|
+
"""Open a new terminal window running the LetsWork chat window."""
|
|
110
|
+
args = (
|
|
111
|
+
f" --url {shlex.quote(url)}"
|
|
112
|
+
f" --token {shlex.quote(token)}"
|
|
113
|
+
f" --role {role}"
|
|
114
|
+
f" --name {shlex.quote(name)}"
|
|
115
|
+
)
|
|
116
|
+
# Prefer the installed script (resolves correctly when venv is active).
|
|
117
|
+
# Fall back to the Python that's running this process.
|
|
118
|
+
chat_bin = shutil.which("letswork-chat")
|
|
119
|
+
if chat_bin:
|
|
120
|
+
cmd = shlex.quote(chat_bin) + args
|
|
121
|
+
else:
|
|
122
|
+
python = shlex.quote(sys.executable)
|
|
123
|
+
cmd = f"{python} -m letswork.tui.chat_app{args}"
|
|
124
|
+
launched = _open_terminal(cmd, project_root)
|
|
125
|
+
if not launched:
|
|
126
|
+
print(f"Run in a new terminal: {cmd}")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LetsWork stdio MCP proxy.
|
|
3
|
+
|
|
4
|
+
Claude Code connects to this as a stdio MCP server (reliable, no streaming issues).
|
|
5
|
+
This proxy forwards all tool calls to the host's HTTP MCP server over Cloudflare.
|
|
6
|
+
|
|
7
|
+
Usage (done automatically by `letswork join`):
|
|
8
|
+
claude mcp add letswork -- letswork-proxy --url <URL> --token <TOKEN>
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
import asyncio
|
|
12
|
+
import argparse
|
|
13
|
+
import httpx
|
|
14
|
+
from mcp.server import Server
|
|
15
|
+
from mcp.server.stdio import stdio_server
|
|
16
|
+
from mcp import types
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_proxy_server(base_url: str, token: str) -> Server:
|
|
20
|
+
"""Create an MCP Server that forwards calls to the remote host."""
|
|
21
|
+
# Ensure URL ends with /mcp
|
|
22
|
+
url = base_url.rstrip("/")
|
|
23
|
+
if not url.endswith("/mcp"):
|
|
24
|
+
url = url + "/mcp"
|
|
25
|
+
|
|
26
|
+
server = Server("letswork-proxy")
|
|
27
|
+
|
|
28
|
+
async def _http_post(payload: dict) -> dict:
|
|
29
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
30
|
+
r = await client.post(
|
|
31
|
+
url,
|
|
32
|
+
json=payload,
|
|
33
|
+
headers={
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Accept": "application/json, text/event-stream",
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
# Parse SSE response: find 'data: {...}' line
|
|
40
|
+
for line in r.text.splitlines():
|
|
41
|
+
if line.startswith("data: "):
|
|
42
|
+
import json
|
|
43
|
+
return json.loads(line[6:])
|
|
44
|
+
raise RuntimeError("No data in response")
|
|
45
|
+
|
|
46
|
+
@server.list_tools()
|
|
47
|
+
async def list_tools() -> list[types.Tool]:
|
|
48
|
+
resp = await _http_post({
|
|
49
|
+
"jsonrpc": "2.0", "id": 1,
|
|
50
|
+
"method": "tools/list", "params": {},
|
|
51
|
+
})
|
|
52
|
+
tools = []
|
|
53
|
+
for t in resp.get("result", {}).get("tools", []):
|
|
54
|
+
schema = t.get("inputSchema", {"type": "object", "properties": {}})
|
|
55
|
+
# Strip 'token' from schema — proxy injects it automatically
|
|
56
|
+
schema = dict(schema)
|
|
57
|
+
props = dict(schema.get("properties", {}))
|
|
58
|
+
props.pop("token", None)
|
|
59
|
+
schema["properties"] = props
|
|
60
|
+
required = [r for r in schema.get("required", []) if r != "token"]
|
|
61
|
+
if required:
|
|
62
|
+
schema["required"] = required
|
|
63
|
+
elif "required" in schema:
|
|
64
|
+
del schema["required"]
|
|
65
|
+
tools.append(types.Tool(
|
|
66
|
+
name=t["name"],
|
|
67
|
+
description=t.get("description", ""),
|
|
68
|
+
inputSchema=schema,
|
|
69
|
+
))
|
|
70
|
+
return tools
|
|
71
|
+
|
|
72
|
+
@server.call_tool()
|
|
73
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
74
|
+
# Inject token automatically so guest doesn't have to think about it
|
|
75
|
+
arguments = {**arguments, "token": token}
|
|
76
|
+
resp = await _http_post({
|
|
77
|
+
"jsonrpc": "2.0", "id": 1,
|
|
78
|
+
"method": "tools/call",
|
|
79
|
+
"params": {"name": name, "arguments": arguments},
|
|
80
|
+
})
|
|
81
|
+
result = resp.get("result", {})
|
|
82
|
+
content = result.get("content", [])
|
|
83
|
+
out = []
|
|
84
|
+
for item in content:
|
|
85
|
+
if item.get("type") == "text":
|
|
86
|
+
out.append(types.TextContent(type="text", text=item["text"]))
|
|
87
|
+
if not out:
|
|
88
|
+
out.append(types.TextContent(type="text", text=str(result)))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
return server
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _run(url: str, token: str) -> None:
|
|
95
|
+
server = make_proxy_server(url, token)
|
|
96
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
97
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
parser = argparse.ArgumentParser(description="LetsWork MCP stdio proxy")
|
|
102
|
+
parser.add_argument("--url", required=True, help="Host MCP URL")
|
|
103
|
+
parser.add_argument("--token", required=True, help="Session token")
|
|
104
|
+
args = parser.parse_args()
|
|
105
|
+
asyncio.run(_run(args.url, args.token))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
|
@@ -3,10 +3,14 @@ import threading
|
|
|
3
3
|
from mcp.client.streamable_http import streamablehttp_client
|
|
4
4
|
from mcp import ClientSession
|
|
5
5
|
|
|
6
|
+
_RECONNECT_DELAYS = [1, 2, 5, 10, 30] # seconds between retries
|
|
7
|
+
|
|
8
|
+
|
|
6
9
|
class RemoteClient:
|
|
7
10
|
"""
|
|
8
|
-
Connects to Host MCP server over streamable-http,
|
|
11
|
+
Connects to Host MCP server over streamable-http,
|
|
9
12
|
exposes sync methods for TUI widgets.
|
|
13
|
+
Auto-reconnects if the connection drops.
|
|
10
14
|
"""
|
|
11
15
|
def __init__(self, mcp_url: str, token: str):
|
|
12
16
|
self.mcp_url = mcp_url
|
|
@@ -15,44 +19,57 @@ class RemoteClient:
|
|
|
15
19
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
16
20
|
self._thread: threading.Thread | None = None
|
|
17
21
|
self._connected = False
|
|
18
|
-
self.
|
|
19
|
-
self.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
self._should_run = False
|
|
23
|
+
self._on_reconnect: callable | None = None
|
|
24
|
+
|
|
25
|
+
def on_reconnect(self, callback: callable) -> None:
|
|
26
|
+
"""Register a callback invoked after each successful reconnect."""
|
|
27
|
+
self._on_reconnect = callback
|
|
28
|
+
|
|
22
29
|
def connect(self) -> bool:
|
|
30
|
+
self._should_run = True
|
|
23
31
|
loop = asyncio.new_event_loop()
|
|
24
32
|
self._loop = loop
|
|
25
33
|
ready_event = threading.Event()
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
|
|
28
35
|
async def _run_loop():
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
self.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
attempt = 0
|
|
37
|
+
while self._should_run:
|
|
38
|
+
try:
|
|
39
|
+
async with streamablehttp_client(self.mcp_url) as (read, write, _):
|
|
40
|
+
async with ClientSession(read, write) as session:
|
|
41
|
+
await session.initialize()
|
|
42
|
+
self._session = session
|
|
43
|
+
self._connected = True
|
|
44
|
+
attempt = 0
|
|
45
|
+
ready_event.set()
|
|
46
|
+
if self._on_reconnect:
|
|
47
|
+
self._on_reconnect()
|
|
48
|
+
while self._connected and self._should_run:
|
|
49
|
+
await asyncio.sleep(0.1)
|
|
50
|
+
except Exception:
|
|
51
|
+
self._connected = False
|
|
52
|
+
self._session = None
|
|
53
|
+
if not ready_event.is_set():
|
|
54
|
+
# First connect failed — signal caller and stop retrying
|
|
37
55
|
ready_event.set()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
return
|
|
57
|
+
# Reconnect with backoff
|
|
58
|
+
delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
|
|
59
|
+
attempt += 1
|
|
60
|
+
await asyncio.sleep(delay)
|
|
61
|
+
|
|
44
62
|
def thread_target():
|
|
45
63
|
loop.run_until_complete(_run_loop())
|
|
46
|
-
|
|
64
|
+
|
|
47
65
|
self._thread = threading.Thread(target=thread_target, daemon=True)
|
|
48
66
|
self._thread.start()
|
|
49
|
-
|
|
67
|
+
|
|
50
68
|
ready_event.wait(timeout=10)
|
|
51
|
-
|
|
52
|
-
return False
|
|
53
|
-
return True
|
|
69
|
+
return self._connected
|
|
54
70
|
|
|
55
71
|
def disconnect(self):
|
|
72
|
+
self._should_run = False
|
|
56
73
|
self._connected = False
|
|
57
74
|
if self._thread:
|
|
58
75
|
self._thread.join(timeout=5)
|
|
@@ -94,6 +111,9 @@ class RemoteClient:
|
|
|
94
111
|
def unlock_file(self, path: str) -> str:
|
|
95
112
|
return self._call_tool("unlock_file", {"token": self.token, "path": path})
|
|
96
113
|
|
|
114
|
+
def register_name(self, display_name: str) -> str:
|
|
115
|
+
return self._call_tool("register_name", {"token": self.token, "display_name": display_name})
|
|
116
|
+
|
|
97
117
|
def send_message(self, message: str) -> str:
|
|
98
118
|
return self._call_tool("send_message", {"token": self.token, "message": message})
|
|
99
119
|
|
|
@@ -9,19 +9,20 @@ app = FastMCP("letswork")
|
|
|
9
9
|
lock_manager = LockManager()
|
|
10
10
|
event_log = EventLog()
|
|
11
11
|
approval_queue: ApprovalQueue | None = None
|
|
12
|
-
|
|
12
|
+
valid_tokens: set[str] = set()
|
|
13
13
|
project_root: str = ""
|
|
14
14
|
token_to_user: dict[str, str] = {}
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def check_auth(provided_token: str) -> bool:
|
|
18
|
-
"""Validates a provided token against
|
|
19
|
-
return validate_token(provided_token,
|
|
18
|
+
"""Validates a provided token against all registered tokens."""
|
|
19
|
+
return any(validate_token(provided_token, t) for t in valid_tokens)
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def register_user(token: str, user_id: str) -> None:
|
|
23
|
-
"""Associate a token with a user_id."""
|
|
23
|
+
"""Associate a token with a user_id and mark it as valid."""
|
|
24
24
|
token_to_user[token] = user_id
|
|
25
|
+
valid_tokens.add(token)
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def get_user(token: str) -> str:
|
|
@@ -206,6 +207,16 @@ def get_status(token: str) -> str:
|
|
|
206
207
|
return "\n".join(status_lines)
|
|
207
208
|
|
|
208
209
|
|
|
210
|
+
@app.tool()
|
|
211
|
+
def register_name(token: str, display_name: str) -> str:
|
|
212
|
+
if not check_auth(token):
|
|
213
|
+
raise ValueError("Unauthorized: invalid token")
|
|
214
|
+
user_id = get_user(token)
|
|
215
|
+
token_to_user[token] = display_name
|
|
216
|
+
event_log.emit(EventType.CONNECTION, display_name, {})
|
|
217
|
+
return f"Registered as {display_name} (was {user_id})"
|
|
218
|
+
|
|
219
|
+
|
|
209
220
|
@app.tool()
|
|
210
221
|
def send_message(token: str, message: str) -> str:
|
|
211
222
|
if not check_auth(token):
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Standalone LetsWork chat window — simple Textual UI."""
|
|
2
|
+
import argparse
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.widgets import Header, Footer, RichLog, Input
|
|
7
|
+
|
|
8
|
+
from letswork.remote_client import RemoteClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChatApp(App):
|
|
12
|
+
TITLE = "LetsWork Chat"
|
|
13
|
+
CSS = """
|
|
14
|
+
Screen { layout: vertical; }
|
|
15
|
+
#messages {
|
|
16
|
+
height: 1fr;
|
|
17
|
+
border: solid $accent;
|
|
18
|
+
padding: 0 1;
|
|
19
|
+
}
|
|
20
|
+
#chat-input { height: 3; dock: bottom; }
|
|
21
|
+
"""
|
|
22
|
+
BINDINGS = [("ctrl+q", "quit", "Quit")]
|
|
23
|
+
|
|
24
|
+
def __init__(self, url: str, token: str, role: str, name: str, **kwargs):
|
|
25
|
+
super().__init__(**kwargs)
|
|
26
|
+
self.url = url
|
|
27
|
+
self.token = token
|
|
28
|
+
self.role = role.lower()
|
|
29
|
+
self.display_name = name
|
|
30
|
+
self._client = RemoteClient(url, token)
|
|
31
|
+
self._connected = False
|
|
32
|
+
self._last_index = 0
|
|
33
|
+
self._poll_in_progress = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def role_label(self) -> str:
|
|
37
|
+
return "HOST" if self.role == "host" else "GUEST"
|
|
38
|
+
|
|
39
|
+
def compose(self) -> ComposeResult:
|
|
40
|
+
yield Header(show_clock=True)
|
|
41
|
+
yield RichLog(id="messages", markup=True, wrap=True)
|
|
42
|
+
yield Input(
|
|
43
|
+
placeholder=f"[{self.role_label}] {self.display_name}: type here and press Enter",
|
|
44
|
+
id="chat-input",
|
|
45
|
+
)
|
|
46
|
+
yield Footer()
|
|
47
|
+
|
|
48
|
+
def on_mount(self) -> None:
|
|
49
|
+
self.sub_title = f"[{self.role_label}] {self.display_name}"
|
|
50
|
+
log = self.query_one("#messages", RichLog)
|
|
51
|
+
log.write("[bold]─── LetsWork Chat ──────────────────────────────────────────[/bold]")
|
|
52
|
+
threading.Thread(target=self._connect, daemon=True).start()
|
|
53
|
+
|
|
54
|
+
def _connect(self) -> None:
|
|
55
|
+
self._connected = self._client.connect()
|
|
56
|
+
if self._connected:
|
|
57
|
+
self._client.register_name(self.display_name)
|
|
58
|
+
|
|
59
|
+
def _update():
|
|
60
|
+
log = self.query_one("#messages", RichLog)
|
|
61
|
+
if self._connected:
|
|
62
|
+
log.write("[dim]connected — start chatting[/dim]")
|
|
63
|
+
self.set_interval(1.0, self._poll)
|
|
64
|
+
else:
|
|
65
|
+
log.write("[bold red]❌ Failed to connect to MCP server[/bold red]")
|
|
66
|
+
|
|
67
|
+
self.call_from_thread(_update)
|
|
68
|
+
|
|
69
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
70
|
+
text = event.value.strip()
|
|
71
|
+
if not text or not self._connected:
|
|
72
|
+
return
|
|
73
|
+
msg = f"[{self.role_label}] {self.display_name}: {text}"
|
|
74
|
+
event.input.value = ""
|
|
75
|
+
threading.Thread(target=self._send, args=(msg,), daemon=True).start()
|
|
76
|
+
|
|
77
|
+
def _send(self, message: str) -> None:
|
|
78
|
+
self._client.send_message(message)
|
|
79
|
+
|
|
80
|
+
def _poll(self) -> None:
|
|
81
|
+
if self._poll_in_progress:
|
|
82
|
+
return
|
|
83
|
+
self._poll_in_progress = True
|
|
84
|
+
threading.Thread(target=self._fetch_events, daemon=True).start()
|
|
85
|
+
|
|
86
|
+
def _fetch_events(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
result = self._client.get_events(self._last_index)
|
|
89
|
+
if not result or result == "no_new_events" or result.startswith("Error"):
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
lines = result.strip().split("\n")
|
|
93
|
+
new_index = self._last_index
|
|
94
|
+
chat_lines = []
|
|
95
|
+
|
|
96
|
+
for line in lines:
|
|
97
|
+
if line.startswith("__INDEX__:"):
|
|
98
|
+
try:
|
|
99
|
+
new_index = int(line.split(":", 1)[1])
|
|
100
|
+
except (ValueError, IndexError):
|
|
101
|
+
pass
|
|
102
|
+
elif "💬" in line:
|
|
103
|
+
# Format: "[HH:MM:SS] 💬 user_id: [ROLE] Name: text"
|
|
104
|
+
try:
|
|
105
|
+
after_emoji = line.split("💬 ", 1)[1] # "user_id: [ROLE] Name: text"
|
|
106
|
+
content = after_emoji.split(": ", 1)[1] # "[ROLE] Name: text"
|
|
107
|
+
if content.startswith("[HOST]"):
|
|
108
|
+
chat_lines.append(f" [bold blue]{content}[/bold blue]")
|
|
109
|
+
elif content.startswith("[GUEST]"):
|
|
110
|
+
chat_lines.append(f" [bold green]{content}[/bold green]")
|
|
111
|
+
else:
|
|
112
|
+
chat_lines.append(f" {content}")
|
|
113
|
+
except (IndexError, ValueError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
def _update():
|
|
117
|
+
self._last_index = new_index
|
|
118
|
+
if chat_lines:
|
|
119
|
+
log = self.query_one("#messages", RichLog)
|
|
120
|
+
for line in chat_lines:
|
|
121
|
+
log.write(line)
|
|
122
|
+
|
|
123
|
+
self.call_from_thread(_update)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
self._poll_in_progress = False
|
|
128
|
+
|
|
129
|
+
def action_quit(self) -> None:
|
|
130
|
+
self._client.disconnect()
|
|
131
|
+
self.exit()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
parser = argparse.ArgumentParser(description="LetsWork Chat Window")
|
|
136
|
+
parser.add_argument("--url", required=True, help="MCP server URL")
|
|
137
|
+
parser.add_argument("--token", required=True, help="Session token")
|
|
138
|
+
parser.add_argument("--role", required=True, choices=["host", "guest"])
|
|
139
|
+
parser.add_argument("--name", required=True, help="Your display name")
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
ChatApp(url=args.url, token=args.token, role=args.role, name=args.name).run()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "letswork"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "2.0.0"
|
|
8
8
|
description = "Real-time collaborative coding via MCP — two developers, one codebase"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -19,6 +19,8 @@ dependencies = [
|
|
|
19
19
|
|
|
20
20
|
[project.scripts]
|
|
21
21
|
letswork = "letswork.cli:cli"
|
|
22
|
+
letswork-proxy = "letswork.proxy:main"
|
|
23
|
+
letswork-chat = "letswork.tui.chat_app:main"
|
|
22
24
|
|
|
23
25
|
[tool.hatch.build.targets.wheel]
|
|
24
26
|
packages = ["letswork"]
|
letswork-0.1.3/letswork/cli.py
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import click
|
|
3
|
-
import letswork.server as server_module
|
|
4
|
-
from letswork.auth import generate_token
|
|
5
|
-
from letswork.tunnel import start_tunnel, stop_tunnel
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@click.group()
|
|
9
|
-
def cli():
|
|
10
|
-
"""LetsWork — real-time collaborative coding via MCP."""
|
|
11
|
-
pass
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@cli.command()
|
|
15
|
-
@click.option("--port", default=8000, type=int, help="Port for the MCP server")
|
|
16
|
-
def start(port):
|
|
17
|
-
"""Start a LetsWork collaboration session."""
|
|
18
|
-
import threading
|
|
19
|
-
from letswork.events import EventLog
|
|
20
|
-
from letswork.launcher import launch_with_claude_code
|
|
21
|
-
|
|
22
|
-
# Set up project root
|
|
23
|
-
project_root = os.getcwd()
|
|
24
|
-
server_module.project_root = project_root
|
|
25
|
-
|
|
26
|
-
# Generate token and register host
|
|
27
|
-
token = generate_token()
|
|
28
|
-
server_module.session_token = token
|
|
29
|
-
server_module.register_user(token, "host")
|
|
30
|
-
|
|
31
|
-
# Set up event log
|
|
32
|
-
event_log = EventLog()
|
|
33
|
-
server_module.event_log = event_log
|
|
34
|
-
|
|
35
|
-
# Set up approval queue
|
|
36
|
-
from letswork.approval import ApprovalQueue
|
|
37
|
-
approval_queue = ApprovalQueue(project_root)
|
|
38
|
-
server_module.approval_queue = approval_queue
|
|
39
|
-
|
|
40
|
-
# Start tunnel
|
|
41
|
-
try:
|
|
42
|
-
url, tunnel_process = start_tunnel(port)
|
|
43
|
-
except RuntimeError as e:
|
|
44
|
-
click.echo(f"Error: {e}")
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
# Print session info
|
|
48
|
-
click.echo("")
|
|
49
|
-
click.echo("╔══════════════════════════════════════════════════╗")
|
|
50
|
-
click.echo("║ LetsWork Session Active ║")
|
|
51
|
-
click.echo("║ ║")
|
|
52
|
-
click.echo(f"║ URL: {url}/mcp")
|
|
53
|
-
click.echo(f"║ Token: {token}")
|
|
54
|
-
click.echo("║ ║")
|
|
55
|
-
click.echo("║ Share both with your collaborator. ║")
|
|
56
|
-
click.echo("╚══════════════════════════════════════════════════╝")
|
|
57
|
-
click.echo("")
|
|
58
|
-
|
|
59
|
-
# Suppress uvicorn access logs before server starts
|
|
60
|
-
import logging
|
|
61
|
-
for _log_name in ("uvicorn", "uvicorn.access", "uvicorn.error", "uvicorn.asgi"):
|
|
62
|
-
_l = logging.getLogger(_log_name)
|
|
63
|
-
_l.setLevel(logging.CRITICAL)
|
|
64
|
-
_l.propagate = False
|
|
65
|
-
logging.getLogger("httpx").setLevel(logging.CRITICAL)
|
|
66
|
-
|
|
67
|
-
# Start MCP server in a background thread
|
|
68
|
-
def run_server():
|
|
69
|
-
import logging as _logging
|
|
70
|
-
import uvicorn.config as _uvc
|
|
71
|
-
|
|
72
|
-
# Replace the entire logging config so uvicorn installs no console handlers
|
|
73
|
-
_uvc.LOGGING_CONFIG = {
|
|
74
|
-
"version": 1,
|
|
75
|
-
"disable_existing_loggers": False,
|
|
76
|
-
"handlers": {
|
|
77
|
-
"null": {"class": "logging.NullHandler"},
|
|
78
|
-
},
|
|
79
|
-
"loggers": {
|
|
80
|
-
"uvicorn": {"handlers": ["null"], "level": "CRITICAL", "propagate": False},
|
|
81
|
-
"uvicorn.error": {"handlers": ["null"], "level": "CRITICAL", "propagate": False},
|
|
82
|
-
"uvicorn.access": {"handlers": ["null"], "level": "CRITICAL", "propagate": False},
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
server_module.app.settings.host = "127.0.0.1"
|
|
87
|
-
server_module.app.settings.port = port
|
|
88
|
-
server_module.app.settings.stateless_http = True
|
|
89
|
-
if hasattr(server_module.app.settings, "transport_security") and server_module.app.settings.transport_security:
|
|
90
|
-
server_module.app.settings.transport_security.enable_dns_rebinding_protection = False
|
|
91
|
-
server_module.app.run(transport="streamable-http")
|
|
92
|
-
|
|
93
|
-
server_thread = threading.Thread(target=run_server, daemon=True)
|
|
94
|
-
server_thread.start()
|
|
95
|
-
|
|
96
|
-
# Give server a moment to start
|
|
97
|
-
import time
|
|
98
|
-
time.sleep(2)
|
|
99
|
-
|
|
100
|
-
# Launch tmux split with TUI + Claude Code
|
|
101
|
-
try:
|
|
102
|
-
launch_with_claude_code(project_root, url, token, port)
|
|
103
|
-
except KeyboardInterrupt:
|
|
104
|
-
click.echo("\nShutting down...")
|
|
105
|
-
finally:
|
|
106
|
-
stop_tunnel(tunnel_process)
|
|
107
|
-
click.echo("Session ended.")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@cli.command()
|
|
111
|
-
@click.argument("url")
|
|
112
|
-
@click.option("--token", prompt="Enter session token", help="Secret token from the host")
|
|
113
|
-
@click.option("--user", default="guest", help="Your username")
|
|
114
|
-
def join(url, token, user):
|
|
115
|
-
"""Join a LetsWork session as a guest."""
|
|
116
|
-
if not url.endswith("/mcp"):
|
|
117
|
-
url = url.rstrip("/") + "/mcp"
|
|
118
|
-
from letswork.events import EventLog
|
|
119
|
-
from letswork.filelock import LockManager
|
|
120
|
-
from letswork.tui.app import LetsWorkApp
|
|
121
|
-
from letswork.launcher import launch_guest_claude_code
|
|
122
|
-
|
|
123
|
-
click.echo(f"Connecting to {url}...")
|
|
124
|
-
click.echo(f"User: {user}")
|
|
125
|
-
click.echo("")
|
|
126
|
-
|
|
127
|
-
# Guest TUI uses a local event log for display
|
|
128
|
-
event_log = EventLog()
|
|
129
|
-
lock_manager = LockManager()
|
|
130
|
-
|
|
131
|
-
# Guest doesn't have local project root — use a temp dir
|
|
132
|
-
# Files are accessed remotely through MCP tools
|
|
133
|
-
project_root = os.getcwd()
|
|
134
|
-
|
|
135
|
-
launch_guest_claude_code(url, token)
|
|
136
|
-
|
|
137
|
-
app = LetsWorkApp(
|
|
138
|
-
project_root=project_root,
|
|
139
|
-
lock_manager=lock_manager,
|
|
140
|
-
event_log=event_log,
|
|
141
|
-
approval_queue=None,
|
|
142
|
-
guest_mode=True,
|
|
143
|
-
mcp_url=url,
|
|
144
|
-
mcp_token=token,
|
|
145
|
-
user_id=user,
|
|
146
|
-
)
|
|
147
|
-
app.run()
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
@cli.command()
|
|
151
|
-
def stop():
|
|
152
|
-
click.echo("In v1, use Ctrl+C in the terminal running 'letswork start' to stop the session.")
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
@cli.command()
|
|
156
|
-
def status():
|
|
157
|
-
click.echo("In v1, use the get_status MCP tool to check session status.")
|
|
158
|
-
click.echo("A standalone status command will be available in v2.")
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if __name__ == "__main__":
|
|
162
|
-
cli()
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import shutil
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def launch_with_claude_code(project_root: str, tunnel_url: str, token: str, port: int) -> None:
|
|
7
|
-
"""Open a new Terminal tab with Claude Code, then launch TUI in current terminal."""
|
|
8
|
-
|
|
9
|
-
if sys.platform == "darwin":
|
|
10
|
-
# Mac: open new Terminal tab with Claude Code
|
|
11
|
-
claude_path = shutil.which("claude")
|
|
12
|
-
if claude_path:
|
|
13
|
-
apple_script = f'''
|
|
14
|
-
tell application "Terminal"
|
|
15
|
-
activate
|
|
16
|
-
do script "cd {project_root} && clear && echo '╔══════════════════════════════════════════════════╗' && echo '║ 🤖 Claude Code — Connected to LetsWork ║' && echo '║ ║' && echo '║ MCP URL: {tunnel_url}/mcp' && echo '║ Token: {token}' && echo '║ ║' && echo '║ Try: list_files, read_file, write_file ║' && echo '╚══════════════════════════════════════════════════╝' && echo '' && claude"
|
|
17
|
-
end tell
|
|
18
|
-
'''
|
|
19
|
-
subprocess.Popen(["osascript", "-e", apple_script])
|
|
20
|
-
else:
|
|
21
|
-
print("⚠️ Claude Code not found. Install with: npm install -g @anthropic-ai/claude-code")
|
|
22
|
-
print(" Open a second terminal and run: claude")
|
|
23
|
-
else:
|
|
24
|
-
# Linux/Windows: just print instructions
|
|
25
|
-
print("Open a second terminal and run:")
|
|
26
|
-
print(f" cd {project_root}")
|
|
27
|
-
print(" claude")
|
|
28
|
-
print("Claude Code will connect to your LetsWork MCP server automatically.")
|
|
29
|
-
|
|
30
|
-
# Launch TUI in current terminal
|
|
31
|
-
import letswork.server as server_module
|
|
32
|
-
from letswork.tui.app import LetsWorkApp
|
|
33
|
-
|
|
34
|
-
app = LetsWorkApp(
|
|
35
|
-
project_root=project_root,
|
|
36
|
-
lock_manager=server_module.lock_manager,
|
|
37
|
-
event_log=server_module.event_log,
|
|
38
|
-
approval_queue=server_module.approval_queue,
|
|
39
|
-
)
|
|
40
|
-
app.run()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def launch_guest_claude_code(url: str, token: str) -> None:
|
|
44
|
-
import threading
|
|
45
|
-
|
|
46
|
-
def _configure():
|
|
47
|
-
subprocess.run(
|
|
48
|
-
["claude", "mcp", "add", "letswork", "--transport", "http", url],
|
|
49
|
-
check=False,
|
|
50
|
-
capture_output=True,
|
|
51
|
-
text=True,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
threading.Thread(target=_configure, daemon=True).start()
|
|
55
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|