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.
Files changed (35) hide show
  1. {letswork-0.1.3 → letswork-2.0.0}/PKG-INFO +1 -1
  2. letswork-2.0.0/letswork/cli.py +159 -0
  3. letswork-2.0.0/letswork/launcher.py +126 -0
  4. letswork-2.0.0/letswork/proxy.py +109 -0
  5. {letswork-0.1.3 → letswork-2.0.0}/letswork/remote_client.py +46 -26
  6. {letswork-0.1.3 → letswork-2.0.0}/letswork/server.py +15 -4
  7. letswork-2.0.0/letswork/tui/chat_app.py +145 -0
  8. {letswork-0.1.3 → letswork-2.0.0}/pyproject.toml +3 -1
  9. letswork-0.1.3/letswork/cli.py +0 -162
  10. letswork-0.1.3/letswork/launcher.py +0 -55
  11. {letswork-0.1.3 → letswork-2.0.0}/.github/workflows/ci.yml +0 -0
  12. {letswork-0.1.3 → letswork-2.0.0}/.github/workflows/publish.yml +0 -0
  13. {letswork-0.1.3 → letswork-2.0.0}/.gitignore +0 -0
  14. {letswork-0.1.3 → letswork-2.0.0}/README.md +0 -0
  15. {letswork-0.1.3 → letswork-2.0.0}/docs/architecture.md +0 -0
  16. {letswork-0.1.3 → letswork-2.0.0}/docs/spec.md +0 -0
  17. {letswork-0.1.3 → letswork-2.0.0}/docs/tasks.md +0 -0
  18. {letswork-0.1.3 → letswork-2.0.0}/letswork/__init__.py +0 -0
  19. {letswork-0.1.3 → letswork-2.0.0}/letswork/approval.py +0 -0
  20. {letswork-0.1.3 → letswork-2.0.0}/letswork/auth.py +0 -0
  21. {letswork-0.1.3 → letswork-2.0.0}/letswork/events.py +0 -0
  22. {letswork-0.1.3 → letswork-2.0.0}/letswork/filelock.py +0 -0
  23. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/__init__.py +0 -0
  24. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/app.py +0 -0
  25. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/approval_panel.py +0 -0
  26. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/chat.py +0 -0
  27. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/file_tree.py +0 -0
  28. {letswork-0.1.3 → letswork-2.0.0}/letswork/tui/file_viewer.py +0 -0
  29. {letswork-0.1.3 → letswork-2.0.0}/letswork/tunnel.py +0 -0
  30. {letswork-0.1.3 → letswork-2.0.0}/server.json +0 -0
  31. {letswork-0.1.3 → letswork-2.0.0}/tests/__init__.py +0 -0
  32. {letswork-0.1.3 → letswork-2.0.0}/tests/test_auth.py +0 -0
  33. {letswork-0.1.3 → letswork-2.0.0}/tests/test_filelock.py +0 -0
  34. {letswork-0.1.3 → letswork-2.0.0}/tests/test_server.py +0 -0
  35. {letswork-0.1.3 → letswork-2.0.0}/tests/test_tunnel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letswork
3
- Version: 0.1.3
3
+ Version: 2.0.0
4
4
  Summary: Real-time collaborative coding via MCP — two developers, one codebase
5
5
  Author: Sai Charan Rajoju
6
6
  License-Expression: MIT
@@ -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._read_stream = None
19
- self._write_stream = None
20
- self._cm_exit = None
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
- error_event = threading.Event()
27
-
34
+
28
35
  async def _run_loop():
29
- try:
30
- async with streamablehttp_client(self.mcp_url) as (read, write, _session_id):
31
- self._read_stream = read
32
- self._write_stream = write
33
- async with ClientSession(read, write) as session:
34
- await session.initialize()
35
- self._session = session
36
- self._connected = True
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
- while self._connected:
39
- await asyncio.sleep(0.1)
40
- except Exception as e:
41
- self._connected = False
42
- error_event.set()
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
- if error_event.is_set() or not ready_event.is_set():
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
- session_token: str = ""
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 the session token. Returns False if invalid."""
19
- return validate_token(provided_token, session_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.1.3"
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"]
@@ -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