letswork 2.0.2__tar.gz → 2.0.4__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 (33) hide show
  1. {letswork-2.0.2 → letswork-2.0.4}/PKG-INFO +1 -1
  2. {letswork-2.0.2 → letswork-2.0.4}/letswork/approval.py +13 -1
  3. {letswork-2.0.2 → letswork-2.0.4}/letswork/cli.py +56 -22
  4. {letswork-2.0.2 → letswork-2.0.4}/letswork/events.py +1 -3
  5. {letswork-2.0.2 → letswork-2.0.4}/letswork/launcher.py +7 -6
  6. {letswork-2.0.2 → letswork-2.0.4}/letswork/server.py +91 -24
  7. {letswork-2.0.2 → letswork-2.0.4}/pyproject.toml +1 -1
  8. {letswork-2.0.2 → letswork-2.0.4}/.github/workflows/ci.yml +0 -0
  9. {letswork-2.0.2 → letswork-2.0.4}/.github/workflows/publish.yml +0 -0
  10. {letswork-2.0.2 → letswork-2.0.4}/.gitignore +0 -0
  11. {letswork-2.0.2 → letswork-2.0.4}/README.md +0 -0
  12. {letswork-2.0.2 → letswork-2.0.4}/docs/architecture.md +0 -0
  13. {letswork-2.0.2 → letswork-2.0.4}/docs/spec.md +0 -0
  14. {letswork-2.0.2 → letswork-2.0.4}/docs/tasks.md +0 -0
  15. {letswork-2.0.2 → letswork-2.0.4}/letswork/__init__.py +0 -0
  16. {letswork-2.0.2 → letswork-2.0.4}/letswork/auth.py +0 -0
  17. {letswork-2.0.2 → letswork-2.0.4}/letswork/filelock.py +0 -0
  18. {letswork-2.0.2 → letswork-2.0.4}/letswork/proxy.py +0 -0
  19. {letswork-2.0.2 → letswork-2.0.4}/letswork/remote_client.py +0 -0
  20. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/__init__.py +0 -0
  21. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/app.py +0 -0
  22. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/approval_panel.py +0 -0
  23. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/chat.py +0 -0
  24. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/chat_app.py +0 -0
  25. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/file_tree.py +0 -0
  26. {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/file_viewer.py +0 -0
  27. {letswork-2.0.2 → letswork-2.0.4}/letswork/tunnel.py +0 -0
  28. {letswork-2.0.2 → letswork-2.0.4}/server.json +0 -0
  29. {letswork-2.0.2 → letswork-2.0.4}/tests/__init__.py +0 -0
  30. {letswork-2.0.2 → letswork-2.0.4}/tests/test_auth.py +0 -0
  31. {letswork-2.0.2 → letswork-2.0.4}/tests/test_filelock.py +0 -0
  32. {letswork-2.0.2 → letswork-2.0.4}/tests/test_server.py +0 -0
  33. {letswork-2.0.2 → letswork-2.0.4}/tests/test_tunnel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letswork
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: Real-time collaborative coding via MCP — two developers, one codebase
5
5
  Author: Sai Charan Rajoju
6
6
  License-Expression: MIT
@@ -25,6 +25,14 @@ class ApprovalQueue:
25
25
  self.project_root = project_root
26
26
  self._pending: dict[str, PendingChange] = {}
27
27
  self._history: list[PendingChange] = []
28
+ self._on_approved = None
29
+ self._on_rejected = None
30
+
31
+ def on_approved(self, callback) -> None:
32
+ self._on_approved = callback
33
+
34
+ def on_rejected(self, callback) -> None:
35
+ self._on_rejected = callback
28
36
 
29
37
  def submit(self, user_id: str, path: str, new_content: str) -> PendingChange:
30
38
  change_id = str(uuid.uuid4())[:8]
@@ -69,16 +77,20 @@ class ApprovalQueue:
69
77
  change.status = ApprovalStatus.APPROVED
70
78
  self._history.append(change)
71
79
  del self._pending[change_id]
80
+ if self._on_approved:
81
+ self._on_approved(change)
72
82
  return True
73
83
 
74
84
  def reject(self, change_id: str) -> bool:
75
85
  if change_id not in self._pending:
76
86
  return False
77
-
87
+
78
88
  change = self._pending[change_id]
79
89
  change.status = ApprovalStatus.REJECTED
80
90
  self._history.append(change)
81
91
  del self._pending[change_id]
92
+ if self._on_rejected:
93
+ self._on_rejected(change)
82
94
  return True
83
95
 
84
96
  def get_pending(self) -> list[PendingChange]:
@@ -33,7 +33,19 @@ def start(port, debug):
33
33
 
34
34
  event_log = EventLog()
35
35
  server_module.event_log = event_log
36
- server_module.approval_queue = ApprovalQueue(project_root)
36
+ approval_queue = ApprovalQueue(project_root)
37
+ server_module.approval_queue = approval_queue
38
+
39
+ def _on_approved(change):
40
+ event_log.emit(EventType.FILE_WRITE, change.user_id,
41
+ {"path": change.path, "status": "approved"})
42
+
43
+ def _on_rejected(change):
44
+ event_log.emit(EventType.FILE_WRITE, change.user_id,
45
+ {"path": change.path, "status": "rejected"})
46
+
47
+ approval_queue.on_approved(_on_approved)
48
+ approval_queue.on_rejected(_on_rejected)
37
49
 
38
50
  # Start tunnel
39
51
  try:
@@ -99,31 +111,53 @@ def start(port, debug):
99
111
  # Open Claude Code in a new Terminal window
100
112
  launch_claude_code(project_root, url, host_token)
101
113
 
114
+ join_cmd = f"letswork join {url} --token {guest_token}"
115
+
102
116
  # Print session info
103
117
  click.echo("")
104
- click.echo("╔══════════════════════════════════════════════════╗")
105
- click.echo("║ 🤝 LetsWork Session Active ║")
106
- click.echo("║ ║")
107
- click.echo(f"║ URL: {url}")
108
- click.echo(f"║ Guest token: {guest_token}")
109
- click.echo("║ ║")
110
- click.echo("║ Share URL + Guest token with your collaborator. ║")
111
- click.echo("║ Press Ctrl+C to stop. ║")
112
- click.echo("╚══════════════════════════════════════════════════╝")
118
+ click.echo("╔══════════════════════════════════════════════════════════════════════╗")
119
+ click.echo("║ 🤝 LetsWork Session Active ║")
120
+ click.echo("║ ║")
121
+ click.echo("║ Send this command to your collaborator:")
122
+ click.echo(f"║ {join_cmd}")
123
+ click.echo("║ ║")
124
+ click.echo("║ Press Ctrl+C to stop. ║")
125
+ click.echo("╚══════════════════════════════════════════════════════════════════════╝")
126
+ click.echo("")
127
+ click.echo("── Notifications ───────────────────────────────────────────────────────")
113
128
  click.echo("")
114
129
 
115
- if debug:
116
- click.echo("[debug] Live activity log:")
117
-
118
- def _log_event(event):
119
- ts = event.timestamp.strftime("%H:%M:%S")
120
- etype = event.event_type.value
121
- click.echo(f" [{ts}] {etype} — {event.data}")
122
-
123
- event_log.on_event(_log_event)
124
- else:
125
- click.echo("Tip: run with --debug to see live tool activity.")
126
- click.echo("")
130
+ # Always-on notifications — important events only
131
+ def _notify(event):
132
+ ts = event.timestamp.strftime("%H:%M:%S")
133
+ if event.event_type == EventType.FILE_WRITE:
134
+ status = event.data.get("status")
135
+ path = event.data.get("path", "?")
136
+ change_id = event.data.get("change_id", "")
137
+ user = event.user_id
138
+ if status == "pending_approval":
139
+ click.echo(f" [{ts}] 📝 {user} submitted change to {path} (ID: {change_id})")
140
+ click.echo(f" run: get_pending_changes in Claude Code to review")
141
+ elif status == "approved":
142
+ click.echo(f" [{ts}] ✅ Change to {path} approved and written to disk")
143
+ elif status == "rejected":
144
+ click.echo(f" [{ts}] ❌ Change to {path} rejected")
145
+ else:
146
+ click.echo(f" [{ts}] ✏️ {user} wrote {path}")
147
+ elif event.event_type == EventType.CONNECTION:
148
+ click.echo(f" [{ts}] 🔌 {event.user_id} connected")
149
+ elif event.event_type == EventType.ERROR:
150
+ click.echo(f" [{ts}] ⚠️ {event.data.get('error', '?')}")
151
+ elif event.event_type == EventType.PING:
152
+ click.echo(f" [{ts}] 🏓 {event.user_id} pinged")
153
+ if debug:
154
+ if event.event_type not in (
155
+ EventType.FILE_WRITE, EventType.CONNECTION,
156
+ EventType.ERROR, EventType.PING,
157
+ ):
158
+ click.echo(f" [{ts}] [debug] {event.event_type.value} — {event.data}")
159
+
160
+ event_log.on_event(_notify)
127
161
 
128
162
  try:
129
163
  while True:
@@ -10,8 +10,8 @@ class EventType(str, Enum):
10
10
  FILE_WRITE = "file_write"
11
11
  FILE_LOCK = "file_lock"
12
12
  FILE_UNLOCK = "file_unlock"
13
- CHAT_MESSAGE = "chat_message"
14
13
  FILE_TREE_REQUEST = "file_tree_request"
14
+ PING = "ping"
15
15
  ERROR = "error"
16
16
 
17
17
  @dataclass
@@ -73,8 +73,6 @@ class EventLog:
73
73
  return f"[{time}] 🔒 {user_id} locked {data.get('path', '?')}"
74
74
  elif event_type == EventType.FILE_UNLOCK:
75
75
  return f"[{time}] 🔓 {user_id} unlocked {data.get('path', '?')}"
76
- elif event_type == EventType.CHAT_MESSAGE:
77
- return f"[{time}] 💬 {user_id}: {data.get('message', '')}"
78
76
  elif event_type == EventType.FILE_TREE_REQUEST:
79
77
  return f"[{time}] 📁 {user_id} viewed file tree"
80
78
 
@@ -15,12 +15,12 @@ def _open_terminal(command: str, project_root: str) -> bool:
15
15
  return True
16
16
 
17
17
  if sys.platform.startswith("linux"):
18
- # Try common Linux terminal emulators in order of preference
18
+ quoted = shlex.quote(project_root)
19
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'"]),
20
+ ("gnome-terminal", ["--", "bash", "-c", f"cd {quoted} && {command}; exec bash"]),
21
+ ("xfce4-terminal", ["-e", f"bash -c 'cd {quoted} && {command}; exec bash'"]),
22
+ ("konsole", ["--noclose", "-e", "bash", "-c", f"cd {quoted} && {command}"]),
23
+ ("xterm", ["-e", f"bash -c 'cd {quoted} && {command}; exec bash'"]),
24
24
  ]:
25
25
  if shutil.which(term):
26
26
  subprocess.Popen([term] + args)
@@ -107,6 +107,7 @@ def launch_guest_claude_code(project_root: str, url: str, token: str) -> None:
107
107
 
108
108
  launched = _open_terminal(_make_banner(url, token), project_root)
109
109
  if not launched:
110
- print("Open a new terminal, cd to your project, and run: claude")
110
+ print(f"\n✅ MCP registered. Now open a new terminal and run:")
111
+ print(f" cd {project_root} && claude")
111
112
 
112
113
 
@@ -42,11 +42,45 @@ def ping(token: str) -> str:
42
42
  if not check_auth(token):
43
43
  raise ValueError("Unauthorized: invalid token")
44
44
  user_id = get_user(token)
45
+ event_log.emit(EventType.PING, user_id, {})
45
46
  return f"pong — connected to {project_root} as {user_id}"
46
47
 
47
48
 
48
49
  @app.tool()
49
- def list_files(token: str, path: str = ".") -> str:
50
+ def get_notifications(token: str) -> str:
51
+ """Returns a summary of what needs attention right now."""
52
+ if not check_auth(token):
53
+ raise ValueError("Unauthorized: invalid token")
54
+ user_id = get_user(token)
55
+ lines = []
56
+
57
+ if approval_queue is not None:
58
+ pending = approval_queue.get_pending()
59
+ if pending:
60
+ lines.append(f"⏳ {len(pending)} change(s) pending approval:")
61
+ for change in pending:
62
+ lines.append(f" • [{change.id}] {change.path} — submitted by {change.user_id}")
63
+ if user_id == "host":
64
+ lines.append(" → use approve_change or reject_change to action them")
65
+ else:
66
+ lines.append(" → waiting for host to review")
67
+ else:
68
+ lines.append("✅ No pending changes")
69
+
70
+ locks = lock_manager.get_locks()
71
+ if locks:
72
+ lines.append(f"🔒 {len(locks)} active lock(s):")
73
+ for path, holder in sorted(locks.items()):
74
+ lines.append(f" • {path} — locked by {holder}")
75
+
76
+ if not lines:
77
+ lines.append("✅ All clear — nothing needs attention")
78
+
79
+ return "\n".join(lines)
80
+
81
+
82
+ @app.tool()
83
+ def list_files(token: str, path: str = ".", recursive: bool = False) -> str:
50
84
  if not check_auth(token):
51
85
  raise ValueError("Unauthorized: invalid token")
52
86
  user_id = get_user(token)
@@ -59,28 +93,34 @@ def list_files(token: str, path: str = ".") -> str:
59
93
 
60
94
  if not os.path.exists(resolved_path):
61
95
  raise ValueError(f"Path not found: {path}")
62
-
63
96
  if not os.path.isdir(resolved_path):
64
97
  raise ValueError(f"Not a directory: {path}")
65
-
66
- listing = os.listdir(resolved_path)
67
- listing.sort()
68
-
69
- if not listing:
70
- return "Directory is empty"
71
-
98
+
72
99
  result_lines = []
73
- for entry in listing:
74
- full_entry_path = os.path.join(resolved_path, entry)
75
- relative_path = os.path.relpath(full_entry_path, project_root)
76
- entry_type = "[dir]" if os.path.isdir(full_entry_path) else "[file]"
77
-
78
- is_locked, holder = lock_manager.is_locked(relative_path)
79
- lock_info = f" [locked by {holder}]" if is_locked else ""
80
-
81
- result_lines.append(f"{entry_type} {relative_path}{lock_info}")
82
-
83
- return "\n".join(result_lines)
100
+
101
+ if recursive:
102
+ for root_dir, dirs, files in os.walk(resolved_path):
103
+ # Skip hidden dirs and common noise
104
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in ("__pycache__", "node_modules", ".venv", "venv"))
105
+ for fname in sorted(files):
106
+ if fname.startswith("."):
107
+ continue
108
+ full = os.path.join(root_dir, fname)
109
+ rel = os.path.relpath(full, project_root)
110
+ is_locked, holder = lock_manager.is_locked(rel)
111
+ lock_info = f" [locked by {holder}]" if is_locked else ""
112
+ result_lines.append(f"[file] {rel}{lock_info}")
113
+ else:
114
+ listing = sorted(os.listdir(resolved_path))
115
+ for entry in listing:
116
+ full_entry_path = os.path.join(resolved_path, entry)
117
+ relative_path = os.path.relpath(full_entry_path, project_root)
118
+ entry_type = "[dir]" if os.path.isdir(full_entry_path) else "[file]"
119
+ is_locked, holder = lock_manager.is_locked(relative_path)
120
+ lock_info = f" [locked by {holder}]" if is_locked else ""
121
+ result_lines.append(f"{entry_type} {relative_path}{lock_info}")
122
+
123
+ return "\n".join(result_lines) if result_lines else "Directory is empty"
84
124
 
85
125
 
86
126
  @app.tool()
@@ -203,18 +243,45 @@ def get_status(token: str) -> str:
203
243
  raise ValueError("Unauthorized: invalid token")
204
244
  status_lines = []
205
245
  status_lines.append(f"Project root: {project_root}")
206
-
246
+
247
+ users = list(set(token_to_user.values()))
248
+ status_lines.append(f"Connected users: {', '.join(users) if users else 'none'}")
249
+
207
250
  locks = lock_manager.get_locks()
208
251
  if not locks:
209
252
  status_lines.append("Active locks: none")
210
253
  else:
211
254
  status_lines.append("Active locks:")
212
- for path, user_id in sorted(locks.items()):
213
- status_lines.append(f" {path} — locked by {user_id}")
214
-
255
+ for path, uid in sorted(locks.items()):
256
+ status_lines.append(f" {path} — locked by {uid}")
257
+
258
+ if approval_queue is not None:
259
+ pending = approval_queue.get_pending()
260
+ if pending:
261
+ status_lines.append(f"Pending approvals: {len(pending)}")
262
+ for change in pending:
263
+ status_lines.append(f" [{change.id}] {change.path} by {change.user_id}")
264
+
215
265
  return "\n".join(status_lines)
216
266
 
217
267
 
268
+ @app.tool()
269
+ def my_pending_changes(token: str) -> str:
270
+ """Show only your own pending changes awaiting approval."""
271
+ if not check_auth(token):
272
+ raise ValueError("Unauthorized: invalid token")
273
+ user_id = get_user(token)
274
+ if approval_queue is None:
275
+ return "No approval system active"
276
+ pending = [c for c in approval_queue.get_pending() if c.user_id == user_id]
277
+ if not pending:
278
+ return "You have no pending changes"
279
+ lines = [f"{len(pending)} change(s) pending approval:"]
280
+ for change in pending:
281
+ lines.append(f" [{change.id}] {change.path}")
282
+ return "\n".join(lines)
283
+
284
+
218
285
  @app.tool()
219
286
  def get_pending_changes(token: str) -> str:
220
287
  if not check_auth(token):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "letswork"
7
- version = "2.0.2"
7
+ version = "2.0.4"
8
8
  description = "Real-time collaborative coding via MCP — two developers, one codebase"
9
9
  readme = "README.md"
10
10
  license = "MIT"
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