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.
- {letswork-2.0.2 → letswork-2.0.4}/PKG-INFO +1 -1
- {letswork-2.0.2 → letswork-2.0.4}/letswork/approval.py +13 -1
- {letswork-2.0.2 → letswork-2.0.4}/letswork/cli.py +56 -22
- {letswork-2.0.2 → letswork-2.0.4}/letswork/events.py +1 -3
- {letswork-2.0.2 → letswork-2.0.4}/letswork/launcher.py +7 -6
- {letswork-2.0.2 → letswork-2.0.4}/letswork/server.py +91 -24
- {letswork-2.0.2 → letswork-2.0.4}/pyproject.toml +1 -1
- {letswork-2.0.2 → letswork-2.0.4}/.github/workflows/ci.yml +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/.github/workflows/publish.yml +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/.gitignore +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/README.md +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/docs/architecture.md +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/docs/spec.md +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/docs/tasks.md +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/__init__.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/auth.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/filelock.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/proxy.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/remote_client.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/__init__.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/app.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/approval_panel.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/chat.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/chat_app.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/file_tree.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tui/file_viewer.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/letswork/tunnel.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/server.json +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/tests/__init__.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/tests/test_auth.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/tests/test_filelock.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/tests/test_server.py +0 -0
- {letswork-2.0.2 → letswork-2.0.4}/tests/test_tunnel.py +0 -0
|
@@ -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
|
-
|
|
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(
|
|
108
|
-
click.echo(f"║
|
|
109
|
-
click.echo("║
|
|
110
|
-
click.echo("║
|
|
111
|
-
click.echo("
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
18
|
+
quoted = shlex.quote(project_root)
|
|
19
19
|
for term, args in [
|
|
20
|
-
("gnome-terminal", ["--", "bash", "-c", f"cd {
|
|
21
|
-
("xfce4-terminal", ["-e", f"bash -c 'cd {
|
|
22
|
-
("konsole", ["--noclose", "-e", "bash", "-c", f"cd {
|
|
23
|
-
("xterm", ["-e", f"bash -c 'cd {
|
|
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("
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
213
|
-
status_lines.append(f" {path} — locked by {
|
|
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):
|
|
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
|
|
File without changes
|