luv-cli 0.2.0__tar.gz → 0.2.1__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.
- {luv_cli-0.2.0 → luv_cli-0.2.1}/PKG-INFO +1 -1
- {luv_cli-0.2.0 → luv_cli-0.2.1}/docs/remote-sessions.md +13 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/luv/__init__.py +101 -7
- {luv_cli-0.2.0 → luv_cli-0.2.1}/pyproject.toml +1 -1
- {luv_cli-0.2.0 → luv_cli-0.2.1}/tests/test_agents.py +125 -2
- {luv_cli-0.2.0 → luv_cli-0.2.1}/.claude/settings.json +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/.gitignore +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/LICENSE +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/README.md +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/claude-output.txt +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/docs/configuration.md +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/docs/sessions.md +0 -0
- {luv_cli-0.2.0 → luv_cli-0.2.1}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments
|
|
5
5
|
Project-URL: Homepage, https://github.com/exospherehost/luv
|
|
6
6
|
Project-URL: Repository, https://github.com/exospherehost/luv
|
|
@@ -343,6 +343,19 @@ happen. Type the prompt into the running agent instead.
|
|
|
343
343
|
It shows the last known state rather than pruning; nothing is lost. When a host
|
|
344
344
|
is gone for good, `luv ls --prune` forgets its entries.
|
|
345
345
|
|
|
346
|
+
**Junk like `35;22;1M` appears at my prompt after a dropped connection**
|
|
347
|
+
|
|
348
|
+
That's mouse-tracking reports. The remote tmux/agent turned mouse tracking on
|
|
349
|
+
in *your* terminal and, having been killed along with the connection, never
|
|
350
|
+
turned it off — so every mouse move now types coordinates at your shell.
|
|
351
|
+
Bracketed paste (`200~` around pastes), a missing cursor, and a stuck
|
|
352
|
+
alternate screen come from the same cause.
|
|
353
|
+
|
|
354
|
+
luv now cleans this up itself: it stays alive as the parent of `ssh` and
|
|
355
|
+
restores the terminal whatever happens to the connection. If you land in this
|
|
356
|
+
state from something else, `reset` (or `stty sane` plus
|
|
357
|
+
`printf '\033[?1003l\033[?1006l\033[?2004l'`) clears it.
|
|
358
|
+
|
|
346
359
|
**A session is wedged**
|
|
347
360
|
|
|
348
361
|
```bash
|
|
@@ -12,6 +12,11 @@ import tempfile
|
|
|
12
12
|
import time
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
try:
|
|
16
|
+
import termios
|
|
17
|
+
except ImportError: # not POSIX — the ssh/tmux handoffs don't run there either
|
|
18
|
+
termios = None
|
|
19
|
+
|
|
15
20
|
LUV_DIR = Path.home() / ".luv"
|
|
16
21
|
CONFIG_FILE = LUV_DIR / "config.json"
|
|
17
22
|
SESSIONS_FILE = LUV_DIR / "sessions.json"
|
|
@@ -1153,21 +1158,109 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
|
|
|
1153
1158
|
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
1154
1159
|
|
|
1155
1160
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1161
|
+
# Terminal modes a full-screen program switches on and is expected to switch
|
|
1162
|
+
# off again on its way out. A connection that dies mid-session never gets to,
|
|
1163
|
+
# and the leftovers are user-visible: mouse tracking turns every mouse move
|
|
1164
|
+
# into "35;22;1M" junk at the shell prompt, bracketed paste wraps pastes in
|
|
1165
|
+
# "200~", and the alternate screen swallows the scrollback.
|
|
1166
|
+
TERM_RESET = (
|
|
1167
|
+
"\x1b[?1000l\x1b[?1001l\x1b[?1002l\x1b[?1003l" # mouse tracking off
|
|
1168
|
+
"\x1b[?1004l" # focus reporting off
|
|
1169
|
+
"\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" # mouse report encodings off
|
|
1170
|
+
"\x1b[?2004l" # bracketed paste off
|
|
1171
|
+
"\x1b[?1049l" # back to the primary screen
|
|
1172
|
+
"\x1b[?1l\x1b>" # normal cursor keys, keypad
|
|
1173
|
+
"\x1b[r" # full-height scroll region
|
|
1174
|
+
"\x1b[?7h\x1b[4l\x1b[?25h\x1b[0m" # wrap, replace, cursor, colours
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def terminal_fd() -> int | None:
|
|
1179
|
+
"""The fd our terminal is on, or None when there isn't one.
|
|
1180
|
+
|
|
1181
|
+
stdin first: it is the one a redirect is least likely to have taken away,
|
|
1182
|
+
and termios wants the terminal device rather than whichever stream happens
|
|
1183
|
+
to still point at it.
|
|
1184
|
+
"""
|
|
1185
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
1186
|
+
try:
|
|
1187
|
+
fd = stream.fileno()
|
|
1188
|
+
except (AttributeError, ValueError, OSError):
|
|
1189
|
+
continue
|
|
1190
|
+
if os.isatty(fd):
|
|
1191
|
+
return fd
|
|
1192
|
+
return None
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
@contextlib.contextmanager
|
|
1196
|
+
def terminal_guard():
|
|
1197
|
+
"""Restore the terminal on the way out, however we get there.
|
|
1158
1198
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1199
|
+
Two layers, because a killed program skips two different kinds of cleanup:
|
|
1200
|
+
termios settings (raw mode, echo) that ssh itself normally puts back, and
|
|
1201
|
+
the DEC private modes the *remote* program turned on, which nothing on this
|
|
1202
|
+
side knows about. Both are cheap no-ops when nothing was broken.
|
|
1203
|
+
"""
|
|
1204
|
+
fd = terminal_fd()
|
|
1205
|
+
saved = None
|
|
1206
|
+
if fd is not None and termios is not None:
|
|
1207
|
+
with contextlib.suppress(termios.error, OSError):
|
|
1208
|
+
saved = termios.tcgetattr(fd)
|
|
1209
|
+
try:
|
|
1210
|
+
yield
|
|
1211
|
+
finally:
|
|
1212
|
+
if fd is not None:
|
|
1213
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1214
|
+
with contextlib.suppress(ValueError, OSError):
|
|
1215
|
+
stream.flush()
|
|
1216
|
+
if saved is not None:
|
|
1217
|
+
with contextlib.suppress(termios.error, OSError):
|
|
1218
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
1219
|
+
with contextlib.suppress(OSError):
|
|
1220
|
+
os.write(fd, TERM_RESET.encode())
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def hand_over(argv: list[str], *, restore: bool = True) -> None:
|
|
1224
|
+
"""Give the terminal to a child and exit with its status. Never returns.
|
|
1225
|
+
|
|
1226
|
+
Without `restore` this is a plain execv, which is the better deal when
|
|
1227
|
+
there is no terminal to leave in a bad state: no process in the middle, and
|
|
1228
|
+
TTY handling, Ctrl-C and the exit code all pass through for free.
|
|
1229
|
+
|
|
1230
|
+
With it, we stay alive as a parent whose only job is to clean up. That
|
|
1231
|
+
costs one process and buys the case this exists for — ssh dying on a broken
|
|
1232
|
+
pipe, taking a remote tmux and its agent TUI with it, with nobody left to
|
|
1233
|
+
turn mouse tracking back off. The child is still in our foreground process
|
|
1234
|
+
group, so it keeps receiving Ctrl-C and SIGWINCH from the terminal driver
|
|
1235
|
+
exactly as it did under execv.
|
|
1161
1236
|
"""
|
|
1237
|
+
if not restore:
|
|
1238
|
+
os.execv(argv[0], argv)
|
|
1239
|
+
else:
|
|
1240
|
+
with terminal_guard():
|
|
1241
|
+
proc = subprocess.Popen(argv)
|
|
1242
|
+
while True:
|
|
1243
|
+
try:
|
|
1244
|
+
code = proc.wait()
|
|
1245
|
+
break
|
|
1246
|
+
except KeyboardInterrupt:
|
|
1247
|
+
# The child got this same Ctrl-C from the tty and decides
|
|
1248
|
+
# for itself what to do with it; outliving it is the point.
|
|
1249
|
+
continue
|
|
1250
|
+
sys.exit(code)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True) -> None:
|
|
1254
|
+
"""Hand the terminal to ssh. Never returns."""
|
|
1162
1255
|
ssh_bin = shutil.which("ssh")
|
|
1163
1256
|
if not ssh_bin:
|
|
1164
1257
|
die("'ssh' not found in PATH")
|
|
1165
1258
|
argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
|
|
1166
|
-
|
|
1259
|
+
hand_over([ssh_bin] + argv[1:], restore=tty)
|
|
1167
1260
|
|
|
1168
1261
|
|
|
1169
1262
|
def attach_session(hc: dict | None, name: str) -> None:
|
|
1170
|
-
"""Attach to a tmux session, locally or over ssh.
|
|
1263
|
+
"""Attach to a tmux session, locally or over ssh. Never returns.
|
|
1171
1264
|
|
|
1172
1265
|
-d detaches other clients so the pane isn't size-clamped to a stale window
|
|
1173
1266
|
left open elsewhere; these are all the same user's sessions.
|
|
@@ -1176,7 +1269,8 @@ def attach_session(hc: dict | None, name: str) -> None:
|
|
|
1176
1269
|
tmux_bin = shutil.which("tmux")
|
|
1177
1270
|
if not tmux_bin:
|
|
1178
1271
|
die("'tmux' not found in PATH")
|
|
1179
|
-
|
|
1272
|
+
hand_over([tmux_bin, "attach", "-d", "-t", name])
|
|
1273
|
+
return
|
|
1180
1274
|
print(f"luv: attaching {name} on {hc['host']}")
|
|
1181
1275
|
exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]))
|
|
1182
1276
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "MIT"
|
|
@@ -122,11 +122,11 @@ class RemoteDispatchTests(unittest.TestCase):
|
|
|
122
122
|
with (patch.object(sys, "argv", ["luv"] + argv),
|
|
123
123
|
patch.dict(luv.os.environ, env, clear=False),
|
|
124
124
|
patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
125
|
-
patch.object(luv
|
|
125
|
+
patch.object(luv, "hand_over") as hand_over,
|
|
126
126
|
contextlib.redirect_stdout(io.StringIO()),
|
|
127
127
|
contextlib.redirect_stderr(io.StringIO())):
|
|
128
128
|
luv.main()
|
|
129
|
-
return
|
|
129
|
+
return hand_over.call_args.args[0] if hand_over.called else None
|
|
130
130
|
|
|
131
131
|
def test_new_workspace_gets_pending_session(self):
|
|
132
132
|
argv = self._dispatch(["myrepo", "fix it"])
|
|
@@ -219,6 +219,129 @@ class RemoteDispatchTests(unittest.TestCase):
|
|
|
219
219
|
self._dispatch(["--local", "-s", "gpu", "myrepo"])
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
class _FakeProc:
|
|
223
|
+
"""A child that can raise KeyboardInterrupt before it finally exits."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, returncode=0, interrupts=0):
|
|
226
|
+
self.returncode = returncode
|
|
227
|
+
self.interrupts = interrupts
|
|
228
|
+
|
|
229
|
+
def wait(self):
|
|
230
|
+
if self.interrupts:
|
|
231
|
+
self.interrupts -= 1
|
|
232
|
+
raise KeyboardInterrupt
|
|
233
|
+
return self.returncode
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TerminalRestoreTests(unittest.TestCase):
|
|
237
|
+
"""A connection that dies must not leave the terminal in the remote
|
|
238
|
+
program's modes — that's the "35;22;1M" junk at the shell prompt."""
|
|
239
|
+
|
|
240
|
+
FD = 42
|
|
241
|
+
SAVED = ["saved", "termios", "attrs"]
|
|
242
|
+
|
|
243
|
+
def _hand_over(self, argv, returncode=0, interrupts=0, **kwargs):
|
|
244
|
+
proc = _FakeProc(returncode, interrupts)
|
|
245
|
+
self.writes = []
|
|
246
|
+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
|
|
247
|
+
patch.object(luv.subprocess, "Popen", return_value=proc) as popen,
|
|
248
|
+
patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
|
|
249
|
+
patch.object(luv.termios, "tcsetattr") as tcsetattr,
|
|
250
|
+
patch.object(luv.os, "execv") as execv,
|
|
251
|
+
patch.object(luv.os, "write",
|
|
252
|
+
side_effect=lambda fd, data: self.writes.append((fd, data))),
|
|
253
|
+
self.assertRaises(SystemExit) as exit_ctx):
|
|
254
|
+
luv.hand_over(argv, **kwargs)
|
|
255
|
+
self.popen, self.tcsetattr, self.execv = popen, tcsetattr, execv
|
|
256
|
+
return exit_ctx.exception.code
|
|
257
|
+
|
|
258
|
+
def _reset_bytes(self):
|
|
259
|
+
return b"".join(data for fd, data in self.writes if fd == self.FD)
|
|
260
|
+
|
|
261
|
+
def test_broken_connection_still_restores_the_terminal(self):
|
|
262
|
+
# 255 is what ssh exits with when the connection drops under it.
|
|
263
|
+
code = self._hand_over(["/bin/ssh", "box", "tmux attach"], returncode=255)
|
|
264
|
+
|
|
265
|
+
self.assertEqual(code, 255, "the child's exit code must still pass through")
|
|
266
|
+
self.assertTrue(self.popen.called, "restore mode must not exec the child away")
|
|
267
|
+
self.assertIn(b"\x1b[?1003l", self._reset_bytes(), "mouse tracking left on")
|
|
268
|
+
self.assertIn(b"\x1b[?1006l", self._reset_bytes(), "SGR mouse reports left on")
|
|
269
|
+
self.assertIn(b"\x1b[?2004l", self._reset_bytes(), "bracketed paste left on")
|
|
270
|
+
self.assertIn(b"\x1b[?1049l", self._reset_bytes(), "alternate screen left on")
|
|
271
|
+
self.assertEqual(self.tcsetattr.call_args.args[0], self.FD)
|
|
272
|
+
self.assertEqual(self.tcsetattr.call_args.args[2], self.SAVED)
|
|
273
|
+
|
|
274
|
+
def test_clean_exit_restores_too(self):
|
|
275
|
+
code = self._hand_over(["/bin/tmux", "attach"], returncode=0)
|
|
276
|
+
|
|
277
|
+
self.assertEqual(code, 0)
|
|
278
|
+
self.assertIn(b"\x1b[?1003l", self._reset_bytes())
|
|
279
|
+
|
|
280
|
+
def test_ctrl_c_does_not_kill_the_parent_before_cleanup(self):
|
|
281
|
+
# Ctrl-C reaches the child through the tty; the parent must outlive it
|
|
282
|
+
# or there is nobody left to clean up after it.
|
|
283
|
+
code = self._hand_over(["/bin/ssh", "box"], returncode=130, interrupts=2)
|
|
284
|
+
|
|
285
|
+
self.assertEqual(code, 130)
|
|
286
|
+
self.assertIn(b"\x1b[?1003l", self._reset_bytes())
|
|
287
|
+
|
|
288
|
+
def test_no_tty_handoff_still_execs(self):
|
|
289
|
+
# -nit streams stream-json into a pipe: no terminal to restore, so keep
|
|
290
|
+
# the cheaper exec and don't leave a process in the middle.
|
|
291
|
+
writes = []
|
|
292
|
+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
|
|
293
|
+
patch.object(luv.os, "execv") as execv,
|
|
294
|
+
patch.object(luv.os, "write", side_effect=writes.append),
|
|
295
|
+
patch.object(luv.subprocess, "Popen") as popen):
|
|
296
|
+
luv.hand_over(["/bin/ssh", "box"], restore=False)
|
|
297
|
+
|
|
298
|
+
self.assertEqual(execv.call_args.args, ("/bin/ssh", ["/bin/ssh", "box"]))
|
|
299
|
+
self.assertFalse(popen.called)
|
|
300
|
+
self.assertEqual(writes, [])
|
|
301
|
+
|
|
302
|
+
def test_guard_is_a_noop_without_a_terminal(self):
|
|
303
|
+
with (patch.object(luv, "terminal_fd", return_value=None),
|
|
304
|
+
patch.object(luv.os, "write") as write,
|
|
305
|
+
patch.object(luv.termios, "tcsetattr") as tcsetattr):
|
|
306
|
+
with luv.terminal_guard():
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
self.assertFalse(write.called)
|
|
310
|
+
self.assertFalse(tcsetattr.called)
|
|
311
|
+
|
|
312
|
+
def test_restore_survives_a_child_that_never_started(self):
|
|
313
|
+
# An OSError out of Popen must not skip the cleanup either.
|
|
314
|
+
writes = []
|
|
315
|
+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
|
|
316
|
+
patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
|
|
317
|
+
patch.object(luv.termios, "tcsetattr"),
|
|
318
|
+
patch.object(luv.os, "write",
|
|
319
|
+
side_effect=lambda fd, data: writes.append(data)),
|
|
320
|
+
patch.object(luv.subprocess, "Popen", side_effect=OSError("boom"))):
|
|
321
|
+
with self.assertRaises(OSError):
|
|
322
|
+
luv.hand_over(["/bin/ssh", "box"])
|
|
323
|
+
|
|
324
|
+
self.assertIn(b"\x1b[?1003l", b"".join(writes))
|
|
325
|
+
|
|
326
|
+
def test_local_attach_goes_through_the_guard(self):
|
|
327
|
+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
328
|
+
patch.object(luv, "hand_over") as hand_over):
|
|
329
|
+
luv.attach_session(None, "luv-myrepo-42")
|
|
330
|
+
|
|
331
|
+
self.assertEqual(hand_over.call_args.args[0],
|
|
332
|
+
["/bin/tmux", "attach", "-d", "-t", "luv-myrepo-42"])
|
|
333
|
+
|
|
334
|
+
def test_remote_attach_asks_for_a_tty_and_restores(self):
|
|
335
|
+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
336
|
+
patch.object(luv, "hand_over") as hand_over,
|
|
337
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
338
|
+
luv.attach_session({"host": "box"}, "luv-myrepo-42")
|
|
339
|
+
|
|
340
|
+
argv = hand_over.call_args.args[0]
|
|
341
|
+
self.assertIn("-t", argv)
|
|
342
|
+
self.assertNotEqual(hand_over.call_args.kwargs.get("restore"), False)
|
|
343
|
+
|
|
344
|
+
|
|
222
345
|
class SessionNameTests(unittest.TestCase):
|
|
223
346
|
def test_illegal_tmux_characters_are_replaced(self):
|
|
224
347
|
self.assertEqual(luv.tmux_session_name("foo.js-7"), "luv-foo_js-7")
|
|
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
|