letswork 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.
- letswork-0.1.0.dist-info/METADATA +120 -0
- letswork-0.1.0.dist-info/RECORD +22 -0
- letswork-0.1.0.dist-info/WHEEL +4 -0
- letswork-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/approval.py +98 -0
- src/auth.py +12 -0
- src/cli.py +132 -0
- src/events.py +80 -0
- src/filelock.py +37 -0
- src/launcher.py +45 -0
- src/remote_client.py +89 -0
- src/server.py +214 -0
- src/tui/__init__.py +1 -0
- src/tui/app.py +265 -0
- src/tui/approval_panel.py +78 -0
- src/tui/chat.py +41 -0
- src/tui/file_tree.py +68 -0
- src/tui/file_viewer.py +158 -0
- src/tui/guest_app.py +203 -0
- src/tui/terminal_panel.py +56 -0
- src/tunnel.py +38 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: letswork
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Real-time collaborative coding via MCP — two developers, one codebase
|
|
5
|
+
Author: Sai Charan Rajoju
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: click>=8.1.0
|
|
9
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
10
|
+
Requires-Dist: requests>=2.28.0
|
|
11
|
+
Requires-Dist: textual[syntax]>=0.50.0
|
|
12
|
+
Requires-Dist: websockets>=12.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# LetsWork
|
|
18
|
+
|
|
19
|
+
**Google Docs for AI-assisted coding** — real-time collaboration on a local codebase using two independent Claude subscriptions.
|
|
20
|
+
|
|
21
|
+
## What is LetsWork?
|
|
22
|
+
|
|
23
|
+
LetsWork is an MCP (Model Context Protocol) server that lets two developers work on the same local codebase simultaneously, each using their own Claude. One developer hosts, the other connects — with file-level locking to prevent conflicts.
|
|
24
|
+
|
|
25
|
+
## How It Works
|
|
26
|
+
|
|
27
|
+
1. Developer A (Host) runs `letswork start` in their project folder
|
|
28
|
+
2. A secure HTTPS tunnel is created automatically via Cloudflare
|
|
29
|
+
3. A one-time URL + secret token is generated
|
|
30
|
+
4. Developer A shares both with Developer B (Guest)
|
|
31
|
+
5. Developer B connects: `claude mcp add letswork --transport http <url>`
|
|
32
|
+
6. Both can now read, write, and list files — with lock protection
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### Install
|
|
37
|
+
```bash
|
|
38
|
+
pip install letswork
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Requirements
|
|
42
|
+
|
|
43
|
+
- Python >= 3.10
|
|
44
|
+
- [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed and available in PATH
|
|
45
|
+
- Git (recommended for conflict safety)
|
|
46
|
+
|
|
47
|
+
### Host (Developer A)
|
|
48
|
+
```bash
|
|
49
|
+
cd /path/to/your/project
|
|
50
|
+
letswork start
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You'll see:
|
|
54
|
+
╔══════════════════════════════════════════════════╗
|
|
55
|
+
║ LetsWork Session Active ║
|
|
56
|
+
║ ║
|
|
57
|
+
║ URL: https://abc123.trycloudflare.com ║
|
|
58
|
+
║ Token: a1b2c3d4e5f6... ║
|
|
59
|
+
║ ║
|
|
60
|
+
║ Share both with your collaborator. ║
|
|
61
|
+
║ Press Ctrl+C to end session. ║
|
|
62
|
+
╚══════════════════════════════════════════════════╝
|
|
63
|
+
|
|
64
|
+
Share the URL and token with your collaborator via Slack, Discord, or text.
|
|
65
|
+
|
|
66
|
+
### Guest (Developer B)
|
|
67
|
+
```bash
|
|
68
|
+
claude mcp add letswork --transport http <URL_FROM_HOST>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Use the token when prompted. You now have full access to the shared codebase through your own Claude.
|
|
72
|
+
|
|
73
|
+
## MCP Tools Available
|
|
74
|
+
|
|
75
|
+
| Tool | Description |
|
|
76
|
+
|------|-------------|
|
|
77
|
+
| `list_files` | List files and directories with lock status |
|
|
78
|
+
| `read_file` | Read file contents (1MB limit) |
|
|
79
|
+
| `write_file` | Write to a file (auto-locks if needed) |
|
|
80
|
+
| `lock_file` | Lock a file for exclusive editing |
|
|
81
|
+
| `unlock_file` | Release a file lock |
|
|
82
|
+
| `get_status` | Show session info and active locks |
|
|
83
|
+
|
|
84
|
+
## Security
|
|
85
|
+
|
|
86
|
+
- Unguessable tunnel URL (random Cloudflare subdomain)
|
|
87
|
+
- Cryptographic secret token (second auth layer)
|
|
88
|
+
- All traffic encrypted via HTTPS
|
|
89
|
+
- Path traversal prevention (no access outside project root)
|
|
90
|
+
- No accounts, no signup, no persistent credentials
|
|
91
|
+
|
|
92
|
+
## CLI Commands
|
|
93
|
+
|
|
94
|
+
| Command | Description |
|
|
95
|
+
|---------|-------------|
|
|
96
|
+
| `letswork start [--port PORT]` | Start session (default port: 8000) |
|
|
97
|
+
| `letswork stop` | Stop instructions (use Ctrl+C in v1) |
|
|
98
|
+
| `letswork status` | Status instructions (use get_status tool in v1) |
|
|
99
|
+
|
|
100
|
+
## Architecture
|
|
101
|
+
Developer A's Machine:
|
|
102
|
+
[Local Codebase] ← [MCP Server] ← [Cloudflare Tunnel] ← HTTPS URL
|
|
103
|
+
↑
|
|
104
|
+
Developer B connects here
|
|
105
|
+
with secret token
|
|
106
|
+
|
|
107
|
+
## Constraints (v1)
|
|
108
|
+
|
|
109
|
+
- Maximum 2 users per session (Host + Guest)
|
|
110
|
+
- Text files only (no binary support)
|
|
111
|
+
- 1MB file size limit per operation
|
|
112
|
+
- File operations only (no shell access for Guest)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
Built with the [Model Context Protocol](https://modelcontextprotocol.io).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
src/__init__.py,sha256=V-C7MVI9SEarXdwLzTU79-D5BNdcJEZ3j_c-cXJoEaY,59
|
|
2
|
+
src/approval.py,sha256=sUU00NUnSKHezX7dvbg1i1zvKboWE3_zkFnY0wvB1ug,2936
|
|
3
|
+
src/auth.py,sha256=iqVHXp6LnSZ5laRNx7kP5DwAW-XmrouANWTTGKdGMbo,370
|
|
4
|
+
src/cli.py,sha256=-yDc3C7pU8rpwHPLefugCuCoLQ-m4Ub6OXKWBGXowoU,4254
|
|
5
|
+
src/events.py,sha256=ajXpZzVvoj3XccDUakUmow0KXGgVV2y-6H8ABnYChmU,2758
|
|
6
|
+
src/filelock.py,sha256=eCgDvG9RkBb5E2IAQk0Vbf_r4kmcEuOmqu-o2S0cA6Q,1286
|
|
7
|
+
src/launcher.py,sha256=j3viEHVw1iZX-T7fx0E6aHfaGdGdcypsho_Kz6cDIX8,2137
|
|
8
|
+
src/remote_client.py,sha256=7ioO43Iqx0fDzBQKySZJ6xi6jXfhduFH-TNovk1r_HE,3429
|
|
9
|
+
src/server.py,sha256=nNRV6Ii9Nyo_ZOwTXLnZ4v7C-UNbt-ZbvP1IJI--67o,7449
|
|
10
|
+
src/tunnel.py,sha256=xjU9yq3EGV4gwEZSjGEPnRVmow_7t8Zf_Ek_0aB_2NY,1467
|
|
11
|
+
src/tui/__init__.py,sha256=b4agVr1NW13jOLM19ubh7ka-D5NUGQkHSSJjkM_mGco,30
|
|
12
|
+
src/tui/app.py,sha256=2SNSmKUmCkJ0PW5DwkngwNJMajmMLzOJxagc5i43wrU,10932
|
|
13
|
+
src/tui/approval_panel.py,sha256=-gA8dlBuD_iM0--Lycx6NRFWaGLVOtBG3mvIZQsho0g,3318
|
|
14
|
+
src/tui/chat.py,sha256=iaQPoiG5jlL2AazBJCwlrUWGhigH92lLLgr0MYL2i_4,1531
|
|
15
|
+
src/tui/file_tree.py,sha256=9CdIZLlyX792t63sVP-pEvhJ79FyHFVZByP-Z4tqAdg,2293
|
|
16
|
+
src/tui/file_viewer.py,sha256=iwqcGRbfxuk0NTKD50O7zrXKHuuWakviwy-euv5_jXM,5866
|
|
17
|
+
src/tui/guest_app.py,sha256=9_YWwOjUHwiIhlZjtBRLdwAqXNCjQjnry0JuRnGDtw8,8011
|
|
18
|
+
src/tui/terminal_panel.py,sha256=-h8wr5oQ6gdTLbkOPa8jkno6PbiFqHOgw3sGopgahXQ,2336
|
|
19
|
+
letswork-0.1.0.dist-info/METADATA,sha256=yM7cWa4RS5Xdkib7b2RWLTDxxbYnUJ1WZS_ZRxfoIlE,3993
|
|
20
|
+
letswork-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
21
|
+
letswork-0.1.0.dist-info/entry_points.txt,sha256=5Sbn4KIDeVd-hiKaNaOpTsR8cP9i_EG0Zew69hXxWAk,41
|
|
22
|
+
letswork-0.1.0.dist-info/RECORD,,
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LetsWork — real-time collaborative coding via MCP."""
|
src/approval.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid
|
|
3
|
+
import difflib
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
class ApprovalStatus(str, Enum):
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
APPROVED = "approved"
|
|
11
|
+
REJECTED = "rejected"
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PendingChange:
|
|
15
|
+
id: str
|
|
16
|
+
user_id: str
|
|
17
|
+
path: str
|
|
18
|
+
new_content: str
|
|
19
|
+
old_content: str
|
|
20
|
+
status: ApprovalStatus = ApprovalStatus.PENDING
|
|
21
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
22
|
+
|
|
23
|
+
class ApprovalQueue:
|
|
24
|
+
def __init__(self, project_root: str):
|
|
25
|
+
self.project_root = project_root
|
|
26
|
+
self._pending: dict[str, PendingChange] = {}
|
|
27
|
+
self._history: list[PendingChange] = []
|
|
28
|
+
|
|
29
|
+
def submit(self, user_id: str, path: str, new_content: str) -> PendingChange:
|
|
30
|
+
change_id = str(uuid.uuid4())[:8]
|
|
31
|
+
abs_path = os.path.join(self.project_root, path)
|
|
32
|
+
|
|
33
|
+
if os.path.isfile(abs_path):
|
|
34
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
35
|
+
old_content = f.read()
|
|
36
|
+
else:
|
|
37
|
+
old_content = ""
|
|
38
|
+
|
|
39
|
+
change = PendingChange(
|
|
40
|
+
id=change_id,
|
|
41
|
+
user_id=user_id,
|
|
42
|
+
path=path,
|
|
43
|
+
new_content=new_content,
|
|
44
|
+
old_content=old_content
|
|
45
|
+
)
|
|
46
|
+
self._pending[change_id] = change
|
|
47
|
+
return change
|
|
48
|
+
|
|
49
|
+
def approve(self, change_id: str) -> bool:
|
|
50
|
+
if change_id not in self._pending:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
change = self._pending[change_id]
|
|
54
|
+
abs_path = os.path.join(self.project_root, change.path)
|
|
55
|
+
|
|
56
|
+
dirname = os.path.dirname(abs_path)
|
|
57
|
+
if dirname:
|
|
58
|
+
os.makedirs(dirname, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
with open(abs_path, "w", encoding="utf-8") as f:
|
|
61
|
+
f.write(change.new_content)
|
|
62
|
+
|
|
63
|
+
change.status = ApprovalStatus.APPROVED
|
|
64
|
+
self._history.append(change)
|
|
65
|
+
del self._pending[change_id]
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def reject(self, change_id: str) -> bool:
|
|
69
|
+
if change_id not in self._pending:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
change = self._pending[change_id]
|
|
73
|
+
change.status = ApprovalStatus.REJECTED
|
|
74
|
+
self._history.append(change)
|
|
75
|
+
del self._pending[change_id]
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def get_pending(self) -> list[PendingChange]:
|
|
79
|
+
return list(self._pending.values())
|
|
80
|
+
|
|
81
|
+
def get_diff(self, change_id: str) -> str:
|
|
82
|
+
if change_id not in self._pending:
|
|
83
|
+
return "Change not found"
|
|
84
|
+
|
|
85
|
+
change = self._pending[change_id]
|
|
86
|
+
diff_lines = list(difflib.unified_diff(
|
|
87
|
+
change.old_content.split("\n"),
|
|
88
|
+
change.new_content.split("\n"),
|
|
89
|
+
fromfile=f"a/{change.path}",
|
|
90
|
+
tofile=f"b/{change.path}",
|
|
91
|
+
lineterm=""
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
diff_str = "\n".join(diff_lines)
|
|
95
|
+
if not diff_str:
|
|
96
|
+
return "No changes detected"
|
|
97
|
+
|
|
98
|
+
return diff_str
|
src/auth.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_token() -> str:
|
|
6
|
+
"""Generate a cryptographically secure URL-safe token for session auth."""
|
|
7
|
+
return secrets.token_urlsafe(32)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_token(provided: str, expected: str) -> bool:
|
|
11
|
+
"""Validate a token using constant-time comparison to prevent timing attacks."""
|
|
12
|
+
return hmac.compare_digest(provided, expected)
|
src/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import click
|
|
3
|
+
import src.server as server_module
|
|
4
|
+
from src.auth import generate_token
|
|
5
|
+
from src.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 os
|
|
19
|
+
import threading
|
|
20
|
+
import src.server as server_module
|
|
21
|
+
from src.auth import generate_token
|
|
22
|
+
from src.tunnel import start_tunnel, stop_tunnel
|
|
23
|
+
from src.events import EventLog
|
|
24
|
+
from src.launcher import launch_with_claude_code
|
|
25
|
+
|
|
26
|
+
# Set up project root
|
|
27
|
+
project_root = os.getcwd()
|
|
28
|
+
server_module.project_root = project_root
|
|
29
|
+
|
|
30
|
+
# Generate token
|
|
31
|
+
token = generate_token()
|
|
32
|
+
server_module.session_token = token
|
|
33
|
+
|
|
34
|
+
# Set up event log
|
|
35
|
+
event_log = EventLog()
|
|
36
|
+
server_module.event_log = event_log
|
|
37
|
+
|
|
38
|
+
# Start tunnel
|
|
39
|
+
try:
|
|
40
|
+
url, tunnel_process = start_tunnel(port)
|
|
41
|
+
except RuntimeError as e:
|
|
42
|
+
click.echo(f"Error: {e}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Print session info
|
|
46
|
+
click.echo("")
|
|
47
|
+
click.echo("╔══════════════════════════════════════════════════╗")
|
|
48
|
+
click.echo("║ LetsWork Session Active ║")
|
|
49
|
+
click.echo("║ ║")
|
|
50
|
+
click.echo(f"║ URL: {url}/mcp")
|
|
51
|
+
click.echo(f"║ Token: {token}")
|
|
52
|
+
click.echo("║ ║")
|
|
53
|
+
click.echo("║ Share both with your collaborator. ║")
|
|
54
|
+
click.echo("╚══════════════════════════════════════════════════╝")
|
|
55
|
+
click.echo("")
|
|
56
|
+
|
|
57
|
+
# Start MCP server in a background thread
|
|
58
|
+
def run_server():
|
|
59
|
+
server_module.app.settings.host = "127.0.0.1"
|
|
60
|
+
server_module.app.settings.port = port
|
|
61
|
+
server_module.app.settings.stateless_http = True
|
|
62
|
+
if hasattr(server_module.app.settings, "transport_security") and server_module.app.settings.transport_security:
|
|
63
|
+
server_module.app.settings.transport_security.enable_dns_rebinding_protection = False
|
|
64
|
+
server_module.app.run(transport="streamable-http")
|
|
65
|
+
|
|
66
|
+
server_thread = threading.Thread(target=run_server, daemon=True)
|
|
67
|
+
server_thread.start()
|
|
68
|
+
|
|
69
|
+
# Give server a moment to start
|
|
70
|
+
import time
|
|
71
|
+
time.sleep(2)
|
|
72
|
+
|
|
73
|
+
# Launch tmux split with TUI + Claude Code
|
|
74
|
+
try:
|
|
75
|
+
launch_with_claude_code(project_root, url, token, port)
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
click.echo("\nShutting down...")
|
|
78
|
+
finally:
|
|
79
|
+
stop_tunnel(tunnel_process)
|
|
80
|
+
click.echo("Session ended.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command()
|
|
84
|
+
@click.argument("url")
|
|
85
|
+
@click.option("--token", prompt="Enter session token", help="Secret token from the host")
|
|
86
|
+
@click.option("--user", default="guest", help="Your username")
|
|
87
|
+
def join(url, token, user):
|
|
88
|
+
"""Join a LetsWork session as a guest."""
|
|
89
|
+
import os
|
|
90
|
+
from src.events import EventLog
|
|
91
|
+
from src.approval import ApprovalQueue
|
|
92
|
+
from src.filelock import LockManager
|
|
93
|
+
from src.tui.app import LetsWorkApp
|
|
94
|
+
|
|
95
|
+
click.echo(f"Connecting to {url}...")
|
|
96
|
+
click.echo(f"User: {user}")
|
|
97
|
+
click.echo("")
|
|
98
|
+
|
|
99
|
+
# Guest TUI uses a local event log for display
|
|
100
|
+
event_log = EventLog()
|
|
101
|
+
lock_manager = LockManager()
|
|
102
|
+
|
|
103
|
+
# Guest doesn't have local project root — use a temp dir
|
|
104
|
+
# Files are accessed remotely through MCP tools
|
|
105
|
+
project_root = os.getcwd()
|
|
106
|
+
|
|
107
|
+
app = LetsWorkApp(
|
|
108
|
+
project_root=project_root,
|
|
109
|
+
lock_manager=lock_manager,
|
|
110
|
+
event_log=event_log,
|
|
111
|
+
approval_queue=None,
|
|
112
|
+
guest_mode=True,
|
|
113
|
+
mcp_url=url,
|
|
114
|
+
mcp_token=token,
|
|
115
|
+
user_id=user,
|
|
116
|
+
)
|
|
117
|
+
app.run()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
def stop():
|
|
122
|
+
click.echo("In v1, use Ctrl+C in the terminal running 'letswork start' to stop the session.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.command()
|
|
126
|
+
def status():
|
|
127
|
+
click.echo("In v1, use the get_status MCP tool to check session status.")
|
|
128
|
+
click.echo("A standalone status command will be available in v2.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
cli()
|
src/events.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
class EventType(str, Enum):
|
|
7
|
+
CONNECTION = "connection"
|
|
8
|
+
DISCONNECTION = "disconnection"
|
|
9
|
+
FILE_READ = "file_read"
|
|
10
|
+
FILE_WRITE = "file_write"
|
|
11
|
+
FILE_LOCK = "file_lock"
|
|
12
|
+
FILE_UNLOCK = "file_unlock"
|
|
13
|
+
CHAT_MESSAGE = "chat_message"
|
|
14
|
+
FILE_TREE_REQUEST = "file_tree_request"
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Event:
|
|
18
|
+
timestamp: datetime
|
|
19
|
+
event_type: EventType
|
|
20
|
+
user_id: str
|
|
21
|
+
data: dict = field(default_factory=dict)
|
|
22
|
+
message: str = ""
|
|
23
|
+
|
|
24
|
+
class EventLog:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self._events: list[Event] = []
|
|
27
|
+
self._listeners: list[Callable] = []
|
|
28
|
+
|
|
29
|
+
def emit(self, event_type: EventType, user_id: str, data: dict | None = None) -> Event:
|
|
30
|
+
if data is None:
|
|
31
|
+
data = {}
|
|
32
|
+
|
|
33
|
+
message = self.format_event(event_type, user_id, data)
|
|
34
|
+
event = Event(
|
|
35
|
+
timestamp=datetime.now(),
|
|
36
|
+
event_type=event_type,
|
|
37
|
+
user_id=user_id,
|
|
38
|
+
data=data,
|
|
39
|
+
message=message
|
|
40
|
+
)
|
|
41
|
+
self._events.append(event)
|
|
42
|
+
|
|
43
|
+
for listener in self._listeners:
|
|
44
|
+
try:
|
|
45
|
+
listener(event)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
return event
|
|
50
|
+
|
|
51
|
+
def on_event(self, callback: Callable) -> None:
|
|
52
|
+
self._listeners.append(callback)
|
|
53
|
+
|
|
54
|
+
def get_recent(self, count: int = 50) -> list[Event]:
|
|
55
|
+
return self._events[-count:]
|
|
56
|
+
|
|
57
|
+
def format_event(self, event_type: EventType, user_id: str, data: dict | None = None) -> str:
|
|
58
|
+
if data is None:
|
|
59
|
+
data = {}
|
|
60
|
+
|
|
61
|
+
time = datetime.now().strftime("%H:%M:%S")
|
|
62
|
+
|
|
63
|
+
if event_type == EventType.CONNECTION:
|
|
64
|
+
return f"[{time}] ✅ {user_id} connected"
|
|
65
|
+
elif event_type == EventType.DISCONNECTION:
|
|
66
|
+
return f"[{time}] ❌ {user_id} disconnected"
|
|
67
|
+
elif event_type == EventType.FILE_READ:
|
|
68
|
+
return f"[{time}] 📖 {user_id} read {data.get('path', '?')}"
|
|
69
|
+
elif event_type == EventType.FILE_WRITE:
|
|
70
|
+
return f"[{time}] ✏️ {user_id} wrote {data.get('path', '?')}"
|
|
71
|
+
elif event_type == EventType.FILE_LOCK:
|
|
72
|
+
return f"[{time}] 🔒 {user_id} locked {data.get('path', '?')}"
|
|
73
|
+
elif event_type == EventType.FILE_UNLOCK:
|
|
74
|
+
return f"[{time}] 🔓 {user_id} unlocked {data.get('path', '?')}"
|
|
75
|
+
elif event_type == EventType.CHAT_MESSAGE:
|
|
76
|
+
return f"[{time}] 💬 {user_id}: {data.get('message', '')}"
|
|
77
|
+
elif event_type == EventType.FILE_TREE_REQUEST:
|
|
78
|
+
return f"[{time}] 📁 {user_id} viewed file tree"
|
|
79
|
+
|
|
80
|
+
return f"[{time}] {event_type} event by {user_id}"
|
src/filelock.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class LockManager:
|
|
2
|
+
"""Manages file-level locks for collaborative editing. Maps file paths to user IDs."""
|
|
3
|
+
|
|
4
|
+
def __init__(self):
|
|
5
|
+
self._locks: dict[str, str] = {}
|
|
6
|
+
|
|
7
|
+
def acquire_lock(self, path: str, user_id: str) -> bool:
|
|
8
|
+
"""Acquire a lock on a file for the given user."""
|
|
9
|
+
if path not in self._locks:
|
|
10
|
+
self._locks[path] = user_id
|
|
11
|
+
return True
|
|
12
|
+
if self._locks[path] == user_id:
|
|
13
|
+
return True
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
def release_lock(self, path: str, user_id: str) -> bool:
|
|
17
|
+
"""Release a lock only if the user is the current owner."""
|
|
18
|
+
if path not in self._locks:
|
|
19
|
+
return False
|
|
20
|
+
if self._locks[path] != user_id:
|
|
21
|
+
return False
|
|
22
|
+
del self._locks[path]
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def get_locks(self) -> dict[str, str]:
|
|
26
|
+
"""Return a copy of all active locks."""
|
|
27
|
+
return self._locks.copy()
|
|
28
|
+
|
|
29
|
+
def is_locked(self, path: str) -> tuple[bool, str | None]:
|
|
30
|
+
"""Check if a file is locked. Returns (is_locked, holder_user_id)."""
|
|
31
|
+
if path in self._locks:
|
|
32
|
+
return (True, self._locks[path])
|
|
33
|
+
return (False, None)
|
|
34
|
+
|
|
35
|
+
def release_all(self) -> None:
|
|
36
|
+
"""Release all locks. Used when session ends."""
|
|
37
|
+
self._locks.clear()
|
src/launcher.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shutil
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def launch_with_claude_code(project_root: str, tunnel_url: str, token: str, port: int) -> None:
|
|
8
|
+
"""Open a new Terminal tab with Claude Code, then launch TUI in current terminal."""
|
|
9
|
+
|
|
10
|
+
if sys.platform == "darwin":
|
|
11
|
+
# Mac: open new Terminal tab with Claude Code
|
|
12
|
+
claude_path = shutil.which("claude")
|
|
13
|
+
if claude_path:
|
|
14
|
+
apple_script = f'''
|
|
15
|
+
tell application "Terminal"
|
|
16
|
+
activate
|
|
17
|
+
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"
|
|
18
|
+
end tell
|
|
19
|
+
'''
|
|
20
|
+
subprocess.Popen(["osascript", "-e", apple_script])
|
|
21
|
+
else:
|
|
22
|
+
print("⚠️ Claude Code not found. Install with: npm install -g @anthropic-ai/claude-code")
|
|
23
|
+
print(" Open a second terminal and run: claude")
|
|
24
|
+
else:
|
|
25
|
+
# Linux/Windows: just print instructions
|
|
26
|
+
print(f"Open a second terminal and run:")
|
|
27
|
+
print(f" cd {project_root}")
|
|
28
|
+
print(f" claude")
|
|
29
|
+
print(f"Claude Code will connect to your LetsWork MCP server automatically.")
|
|
30
|
+
|
|
31
|
+
# Launch TUI in current terminal
|
|
32
|
+
import src.server as server_module
|
|
33
|
+
from src.tui.app import LetsWorkApp
|
|
34
|
+
|
|
35
|
+
app = LetsWorkApp(
|
|
36
|
+
project_root=project_root,
|
|
37
|
+
lock_manager=server_module.lock_manager,
|
|
38
|
+
event_log=server_module.event_log,
|
|
39
|
+
)
|
|
40
|
+
app.run()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def kill_session() -> None:
|
|
44
|
+
"""No-op."""
|
|
45
|
+
pass
|
src/remote_client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RemoteClient:
|
|
7
|
+
"""Calls LetsWork MCP tools over HTTP. Guest TUI uses this instead of local file access."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, mcp_url: str, token: str):
|
|
10
|
+
self.mcp_url = mcp_url.rstrip("/")
|
|
11
|
+
self.token = token
|
|
12
|
+
self.session_id = None
|
|
13
|
+
|
|
14
|
+
def _call_mcp(self, method: str, params: dict = None) -> dict:
|
|
15
|
+
payload = {
|
|
16
|
+
"jsonrpc": "2.0",
|
|
17
|
+
"id": str(uuid.uuid4()),
|
|
18
|
+
"method": method,
|
|
19
|
+
"params": params or {}
|
|
20
|
+
}
|
|
21
|
+
headers = {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Accept": "application/json, text/event-stream",
|
|
24
|
+
}
|
|
25
|
+
if self.session_id:
|
|
26
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
27
|
+
try:
|
|
28
|
+
response = requests.post(self.mcp_url, json=payload, headers=headers, timeout=15)
|
|
29
|
+
if "Mcp-Session-Id" in response.headers:
|
|
30
|
+
self.session_id = response.headers["Mcp-Session-Id"]
|
|
31
|
+
content_type = response.headers.get("Content-Type", "")
|
|
32
|
+
if "text/event-stream" in content_type:
|
|
33
|
+
# Parse the last "data:" line as JSON
|
|
34
|
+
last_data = None
|
|
35
|
+
for line in response.text.splitlines():
|
|
36
|
+
if line.startswith("data:"):
|
|
37
|
+
last_data = line[len("data:"):].strip()
|
|
38
|
+
if last_data:
|
|
39
|
+
return json.loads(last_data)
|
|
40
|
+
return {}
|
|
41
|
+
return response.json()
|
|
42
|
+
except Exception:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
def initialize(self) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
self._call_mcp("initialize", {
|
|
48
|
+
"protocolVersion": "2025-03-26",
|
|
49
|
+
"capabilities": {},
|
|
50
|
+
"clientInfo": {"name": "letswork-guest", "version": "1.0"}
|
|
51
|
+
})
|
|
52
|
+
self._call_mcp("notifications/initialized", {})
|
|
53
|
+
return True
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def call_tool(self, tool_name: str, arguments: dict = None) -> str:
|
|
58
|
+
try:
|
|
59
|
+
result = self._call_mcp("tools/call", {"name": tool_name, "arguments": arguments or {}})
|
|
60
|
+
try:
|
|
61
|
+
return result["result"]["content"][0]["text"]
|
|
62
|
+
except (KeyError, IndexError, TypeError):
|
|
63
|
+
return str(result)
|
|
64
|
+
except requests.RequestException:
|
|
65
|
+
return "Error: connection failed"
|
|
66
|
+
|
|
67
|
+
def list_files(self, path: str = ".") -> str:
|
|
68
|
+
return self.call_tool("list_files", {"path": path})
|
|
69
|
+
|
|
70
|
+
def read_file(self, path: str) -> str:
|
|
71
|
+
return self.call_tool("read_file", {"path": path})
|
|
72
|
+
|
|
73
|
+
def write_file(self, path: str, content: str, user_id: str) -> str:
|
|
74
|
+
return self.call_tool("write_file", {"path": path, "content": content, "user_id": user_id})
|
|
75
|
+
|
|
76
|
+
def lock_file(self, path: str, user_id: str) -> str:
|
|
77
|
+
return self.call_tool("lock_file", {"path": path, "user_id": user_id})
|
|
78
|
+
|
|
79
|
+
def unlock_file(self, path: str, user_id: str) -> str:
|
|
80
|
+
return self.call_tool("unlock_file", {"path": path, "user_id": user_id})
|
|
81
|
+
|
|
82
|
+
def send_message(self, user_id: str, message: str) -> str:
|
|
83
|
+
return self.call_tool("send_message", {"user_id": user_id, "message": message})
|
|
84
|
+
|
|
85
|
+
def get_events(self, since_index: int = 0) -> str:
|
|
86
|
+
return self.call_tool("get_events", {"since_index": since_index})
|
|
87
|
+
|
|
88
|
+
def get_status(self) -> str:
|
|
89
|
+
return self.call_tool("get_status", {})
|