luv-cli 0.6.0__tar.gz → 0.6.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.6.0 → luv_cli-0.6.1}/PKG-INFO +1 -1
- {luv_cli-0.6.0 → luv_cli-0.6.1}/docs/remote-sessions.md +6 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/luv/__init__.py +35 -6
- {luv_cli-0.6.0 → luv_cli-0.6.1}/pyproject.toml +1 -1
- {luv_cli-0.6.0 → luv_cli-0.6.1}/tests/test_agents.py +48 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/.claude/settings.json +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/.failproofai/policies/version-policies.mjs +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/.gitignore +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/LICENSE +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/README.md +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/claude-output.txt +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/docs/configuration.md +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/docs/sessions.md +0 -0
- {luv_cli-0.6.0 → luv_cli-0.6.1}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -265,6 +265,12 @@ luv: session ended unexpectedly — continue it with:
|
|
|
265
265
|
luv continue myrepo 42
|
|
266
266
|
```
|
|
267
267
|
|
|
268
|
+
The number is there even when you never typed one: `luv myrepo "…"` doesn't
|
|
269
|
+
know which workspace it will get — the remote reads that off `gh api` — so the
|
|
270
|
+
line is worked out at the point it is printed, by asking the session which
|
|
271
|
+
folder it ended up in. Without it the hint would name the repo alone, and that
|
|
272
|
+
attaches to whichever of its sessions is newest, which may not be this one.
|
|
273
|
+
|
|
268
274
|
Copy that line and run it. If the agent took the tmux session down with it,
|
|
269
275
|
there is nothing left to attach to; `luv continue` says so and hands you
|
|
270
276
|
`luv myrepo 42 -r`, which reopens the workspace and resumes the conversation.
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import threading
|
|
15
15
|
import time
|
|
16
|
+
from collections.abc import Callable
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
try:
|
|
@@ -1680,6 +1681,19 @@ def continue_hint(repo: str | None, workspace: str | None = None) -> str:
|
|
|
1680
1681
|
return " ".join(parts)
|
|
1681
1682
|
|
|
1682
1683
|
|
|
1684
|
+
def dispatched_workspace(hc: dict, sid: str) -> str | None:
|
|
1685
|
+
"""The workspace a dispatched session landed in, asked of the host.
|
|
1686
|
+
|
|
1687
|
+
Dispatch only knows the folder name when the number was on the command
|
|
1688
|
+
line; otherwise the remote reads it off gh api and stamps it onto its own
|
|
1689
|
+
tmux session, and asking is the only way back to it from here.
|
|
1690
|
+
"""
|
|
1691
|
+
for row in query_tmux(hc) or []:
|
|
1692
|
+
if row["id"] == sid:
|
|
1693
|
+
return row["workspace"] or None
|
|
1694
|
+
return None
|
|
1695
|
+
|
|
1696
|
+
|
|
1683
1697
|
def reopen_hint(args: list[str]) -> str:
|
|
1684
1698
|
"""The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
|
|
1685
1699
|
|
|
@@ -1692,7 +1706,7 @@ def reopen_hint(args: list[str]) -> str:
|
|
|
1692
1706
|
|
|
1693
1707
|
|
|
1694
1708
|
def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
1695
|
-
hint: str | None = None) -> None:
|
|
1709
|
+
hint: str | Callable[[], str] | None = None) -> None:
|
|
1696
1710
|
"""Give the terminal to a child and exit with its status. Never returns.
|
|
1697
1711
|
|
|
1698
1712
|
Without `restore` this is a plain execv, which is the better deal when
|
|
@@ -1716,7 +1730,9 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
|
1716
1730
|
is either a detach or the agent finishing, and neither wants advice; a bad
|
|
1717
1731
|
one is usually ssh losing the connection out from under a session that is
|
|
1718
1732
|
still running on the other side, and the terminal it lands back in should
|
|
1719
|
-
not have to be told twice how to get there.
|
|
1733
|
+
not have to be told twice how to get there. It may be a callable, for the
|
|
1734
|
+
hint that cannot be written until the session it describes has run: only
|
|
1735
|
+
the bad exit pays for working it out, and only once.
|
|
1720
1736
|
"""
|
|
1721
1737
|
if not restore:
|
|
1722
1738
|
os.execv(argv[0], argv)
|
|
@@ -1734,15 +1750,16 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
|
1734
1750
|
# for itself what to do with it; outliving it is the point.
|
|
1735
1751
|
continue
|
|
1736
1752
|
if code != 0 and hint:
|
|
1753
|
+
line = hint() if callable(hint) else hint
|
|
1737
1754
|
# After the guard, not inside it: a terminal still in the remote
|
|
1738
1755
|
# program's modes is no place to print something to be copied.
|
|
1739
1756
|
print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
|
|
1740
|
-
f" {
|
|
1757
|
+
f" {line}\n", file=sys.stderr)
|
|
1741
1758
|
sys.exit(code)
|
|
1742
1759
|
|
|
1743
1760
|
|
|
1744
1761
|
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
|
|
1745
|
-
hint: str | None = None) -> None:
|
|
1762
|
+
hint: str | Callable[[], str] | None = None) -> None:
|
|
1746
1763
|
"""Hand the terminal to ssh. Never returns."""
|
|
1747
1764
|
ssh_bin = shutil.which("ssh")
|
|
1748
1765
|
if not ssh_bin:
|
|
@@ -1751,7 +1768,8 @@ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
|
|
|
1751
1768
|
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
|
|
1752
1769
|
|
|
1753
1770
|
|
|
1754
|
-
def attach_session(hc: dict | None, name: str, *,
|
|
1771
|
+
def attach_session(hc: dict | None, name: str, *,
|
|
1772
|
+
hint: str | Callable[[], str] | None = None) -> None:
|
|
1755
1773
|
"""Attach to a tmux session, locally or over ssh. Never returns.
|
|
1756
1774
|
|
|
1757
1775
|
-d detaches other clients so the pane isn't size-clamped to a stale window
|
|
@@ -1879,7 +1897,8 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
|
|
|
1879
1897
|
|
|
1880
1898
|
# Only a tmux session is there to come back to; -nit and --clean run to
|
|
1881
1899
|
# completion over ssh and have nothing to continue.
|
|
1882
|
-
|
|
1900
|
+
repo = (meta or {}).get("repo")
|
|
1901
|
+
hint = continue_hint(repo, workspace) if use_tmux else None
|
|
1883
1902
|
print(f"luv: {hc['host']} — {session or 'no tmux'}")
|
|
1884
1903
|
if detach:
|
|
1885
1904
|
r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
|
|
@@ -1887,6 +1906,16 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
|
|
|
1887
1906
|
die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
|
|
1888
1907
|
print(f"luv: started detached — attach with: {hint or 'luv continue'}")
|
|
1889
1908
|
return
|
|
1909
|
+
def late_hint() -> str:
|
|
1910
|
+
"""The same hint, worked out after the session rather than before it."""
|
|
1911
|
+
return continue_hint(repo, dispatched_workspace(hc, sid))
|
|
1912
|
+
|
|
1913
|
+
# A dispatch without a number is the ordinary case, and the repo on its own
|
|
1914
|
+
# points at whichever of its sessions is newest — which by then may not be
|
|
1915
|
+
# this one. Deferring costs a round trip only when a session breaks, and by
|
|
1916
|
+
# then the remote has long since picked its number and stamped it on tmux.
|
|
1917
|
+
if use_tmux and repo and not workspace:
|
|
1918
|
+
hint = late_hint
|
|
1890
1919
|
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
|
|
1891
1920
|
hint=hint)
|
|
1892
1921
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.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"
|
|
@@ -479,6 +479,54 @@ class ContinueHintTests(unittest.TestCase):
|
|
|
479
479
|
self.assertEqual(hand_over.call_args.kwargs.get("hint"),
|
|
480
480
|
"luv continue myrepo 42")
|
|
481
481
|
|
|
482
|
+
def _dispatch_hint(self, tmux_rows, **kwargs):
|
|
483
|
+
"""The hint a numberless dispatch hands back, against a host that
|
|
484
|
+
answers with `tmux_rows`."""
|
|
485
|
+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
486
|
+
patch.object(luv, "record_session"),
|
|
487
|
+
patch.object(luv, "port_watch", return_value=None),
|
|
488
|
+
patch.object(luv, "query_tmux", return_value=tmux_rows) as query,
|
|
489
|
+
patch.object(luv, "hand_over") as hand_over,
|
|
490
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
491
|
+
luv.dispatch_remote({"host": "box"}, ["myrepo"],
|
|
492
|
+
meta={"repo": "myrepo", "org": "acme"}, **kwargs)
|
|
493
|
+
hint = hand_over.call_args.kwargs.get("hint")
|
|
494
|
+
# Nothing is asked of the host until the hint is actually needed.
|
|
495
|
+
self.assertFalse(query.called)
|
|
496
|
+
return hint() if callable(hint) else hint
|
|
497
|
+
|
|
498
|
+
def test_a_session_dispatched_without_a_number_finds_its_own(self):
|
|
499
|
+
# The number comes off gh api on the remote, so the only way to name it
|
|
500
|
+
# is to ask the session that picked it — once, and only if it breaks.
|
|
501
|
+
rows = [{"id": "other123", "session": "luv-myrepo-box-7",
|
|
502
|
+
"workspace": "myrepo-box-7", "attached": False, "activity": 0}]
|
|
503
|
+
with patch.object(luv, "new_session_id", return_value="abc12345"):
|
|
504
|
+
rows.append({"id": "abc12345", "session": "luv-myrepo-box-42",
|
|
505
|
+
"workspace": "myrepo-box-42", "attached": True,
|
|
506
|
+
"activity": 0})
|
|
507
|
+
hint = self._dispatch_hint(rows)
|
|
508
|
+
|
|
509
|
+
self.assertEqual(hint, "luv continue myrepo 42")
|
|
510
|
+
|
|
511
|
+
def test_an_unreachable_host_still_hands_back_a_runnable_line(self):
|
|
512
|
+
# query_tmux returns None when the host never answered — which is the
|
|
513
|
+
# usual reason a session broke in the first place.
|
|
514
|
+
self.assertEqual(self._dispatch_hint(None), "luv continue myrepo")
|
|
515
|
+
|
|
516
|
+
def test_a_detached_start_does_not_wait_on_a_number(self):
|
|
517
|
+
# Nothing has been cloned yet, so there is no number to find; the line
|
|
518
|
+
# printed here has to be one, not a deferred callable.
|
|
519
|
+
with (patch.object(luv, "run", return_value=_FakeProc(0)),
|
|
520
|
+
patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
521
|
+
patch.object(luv, "record_session"),
|
|
522
|
+
patch.object(luv, "query_tmux") as query,
|
|
523
|
+
contextlib.redirect_stdout(io.StringIO()) as out):
|
|
524
|
+
luv.dispatch_remote({"host": "box"}, ["myrepo"], detach=True,
|
|
525
|
+
meta={"repo": "myrepo", "org": "acme"})
|
|
526
|
+
|
|
527
|
+
self.assertFalse(query.called)
|
|
528
|
+
self.assertIn("attach with: luv continue myrepo", out.getvalue())
|
|
529
|
+
|
|
482
530
|
def test_a_run_with_no_session_behind_it_gets_no_hint(self):
|
|
483
531
|
# -nit streams to a pipe and exits; there is nothing to continue.
|
|
484
532
|
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
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
|