luv-cli 0.5.1__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.5.1 → luv_cli-0.6.1}/PKG-INFO +27 -1
- {luv_cli-0.5.1 → luv_cli-0.6.1}/README.md +26 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/docs/configuration.md +29 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/docs/remote-sessions.md +19 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/luv/__init__.py +121 -9
- {luv_cli-0.5.1 → luv_cli-0.6.1}/pyproject.toml +1 -1
- {luv_cli-0.5.1 → luv_cli-0.6.1}/tests/test_agents.py +144 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/.claude/settings.json +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/.failproofai/policies/version-policies.mjs +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/.gitignore +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/LICENSE +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/claude-output.txt +0 -0
- {luv_cli-0.5.1 → luv_cli-0.6.1}/docs/sessions.md +0 -0
- {luv_cli-0.5.1 → 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.
|
|
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
|
|
@@ -169,6 +169,32 @@ Handover flags:
|
|
|
169
169
|
| `--purge` | Delete the source folder once the copy is verified |
|
|
170
170
|
| `-y` | Skip the "an agent may still be running" confirmation |
|
|
171
171
|
|
|
172
|
+
## The session's environment
|
|
173
|
+
|
|
174
|
+
A session luv starts for you — a remote dispatch, a handover, a detached start
|
|
175
|
+
— is exec'd by tmux or ssh with no shell in between, so it never sources
|
|
176
|
+
`~/.bashrc` or `~/.zshrc` and would otherwise run with whatever environment the
|
|
177
|
+
tmux server happened to start with. luv asks your login shell (`$SHELL -lic`)
|
|
178
|
+
for its environment and fills in the gaps before cloning or launching anything,
|
|
179
|
+
so the agent gets the API keys and the `PATH` you'd have in a terminal there.
|
|
180
|
+
|
|
181
|
+
Precedence, highest first:
|
|
182
|
+
|
|
183
|
+
1. Anything already set — `FOO=bar luv …`, or `-e` — is a decision about *this*
|
|
184
|
+
session and is never overwritten.
|
|
185
|
+
2. Your rc's exports fill in the rest. `PATH` is merged instead of replaced:
|
|
186
|
+
entries the rc adds (nvm, pyenv, `~/.local/bin`) are appended after the ones
|
|
187
|
+
already there.
|
|
188
|
+
|
|
189
|
+
The probe runs on the machine the session runs on, so a remote session picks up
|
|
190
|
+
the *remote* user's rc. It is skipped when you run `luv` yourself — that
|
|
191
|
+
process already inherited your shell — and can be turned off entirely with
|
|
192
|
+
`luv config set shell_env false`. An rc that hangs is given 15 seconds and then
|
|
193
|
+
ignored.
|
|
194
|
+
|
|
195
|
+
Docker sessions are the exception: the container has its own environment, and
|
|
196
|
+
only `-e` crosses into it (see below).
|
|
197
|
+
|
|
172
198
|
## Docker dev environments
|
|
173
199
|
|
|
174
200
|
If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
|
|
@@ -146,6 +146,32 @@ Handover flags:
|
|
|
146
146
|
| `--purge` | Delete the source folder once the copy is verified |
|
|
147
147
|
| `-y` | Skip the "an agent may still be running" confirmation |
|
|
148
148
|
|
|
149
|
+
## The session's environment
|
|
150
|
+
|
|
151
|
+
A session luv starts for you — a remote dispatch, a handover, a detached start
|
|
152
|
+
— is exec'd by tmux or ssh with no shell in between, so it never sources
|
|
153
|
+
`~/.bashrc` or `~/.zshrc` and would otherwise run with whatever environment the
|
|
154
|
+
tmux server happened to start with. luv asks your login shell (`$SHELL -lic`)
|
|
155
|
+
for its environment and fills in the gaps before cloning or launching anything,
|
|
156
|
+
so the agent gets the API keys and the `PATH` you'd have in a terminal there.
|
|
157
|
+
|
|
158
|
+
Precedence, highest first:
|
|
159
|
+
|
|
160
|
+
1. Anything already set — `FOO=bar luv …`, or `-e` — is a decision about *this*
|
|
161
|
+
session and is never overwritten.
|
|
162
|
+
2. Your rc's exports fill in the rest. `PATH` is merged instead of replaced:
|
|
163
|
+
entries the rc adds (nvm, pyenv, `~/.local/bin`) are appended after the ones
|
|
164
|
+
already there.
|
|
165
|
+
|
|
166
|
+
The probe runs on the machine the session runs on, so a remote session picks up
|
|
167
|
+
the *remote* user's rc. It is skipped when you run `luv` yourself — that
|
|
168
|
+
process already inherited your shell — and can be turned off entirely with
|
|
169
|
+
`luv config set shell_env false`. An rc that hangs is given 15 seconds and then
|
|
170
|
+
ignored.
|
|
171
|
+
|
|
172
|
+
Docker sessions are the exception: the container has its own environment, and
|
|
173
|
+
only `-e` crosses into it (see below).
|
|
174
|
+
|
|
149
175
|
## Docker dev environments
|
|
150
176
|
|
|
151
177
|
If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
|
|
@@ -70,6 +70,35 @@ pushed. Workspaces created before slugs existed keep working.
|
|
|
70
70
|
*string, default `~/prs`.* Where workspace folders are created on **this**
|
|
71
71
|
machine. `~` is expanded.
|
|
72
72
|
|
|
73
|
+
### `shell_env`
|
|
74
|
+
|
|
75
|
+
*boolean, default `true`.* Whether a session luv starts should inherit your
|
|
76
|
+
shell's environment.
|
|
77
|
+
|
|
78
|
+
tmux and ssh exec their command directly, so a remote dispatch, a handover, or
|
|
79
|
+
a detached start never sources `~/.bashrc` or `~/.zshrc` — it runs with the
|
|
80
|
+
environment the tmux server was started with, frozen at whenever that server
|
|
81
|
+
first came up. Anything you export from your rc (the API key an agent
|
|
82
|
+
authenticates with, the `PATH` entry that makes `codex` resolvable) is simply
|
|
83
|
+
absent, and only for the sessions luv started for you.
|
|
84
|
+
|
|
85
|
+
With this on, luv runs `$SHELL -lic` on the machine the session runs on and
|
|
86
|
+
fills in what's missing before it clones or launches anything. Values already
|
|
87
|
+
set win, so `FOO=bar luv …` and `-e` still decide; `PATH` is merged rather than
|
|
88
|
+
replaced, with the rc's entries appended. An rc that never returns is abandoned
|
|
89
|
+
after 15 seconds.
|
|
90
|
+
|
|
91
|
+
Set it to `false` if your rc is slow, or if you want sessions to see only what
|
|
92
|
+
you pass them explicitly:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
luv config set shell_env false
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This is read on the machine the session runs on, so a remote host can opt out
|
|
99
|
+
independently of your laptop. It does not apply to Docker sessions — the
|
|
100
|
+
container has its own environment, and only `-e` crosses into it.
|
|
101
|
+
|
|
73
102
|
### `remote.host`
|
|
74
103
|
|
|
75
104
|
*string.* SSH destination — an `~/.ssh/config` alias (`box`), a `user@host`, or
|
|
@@ -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.
|
|
@@ -521,6 +527,19 @@ The key isn't installed or isn't being offered. Test with `ssh -v box`. Check
|
|
|
521
527
|
`gh` is authenticated on your laptop but not on the remote. luv numbers
|
|
522
528
|
workspaces with `gh api` *on the remote*, so it needs its own login there.
|
|
523
529
|
|
|
530
|
+
**The agent can't find an API key / a tool it needs is missing**
|
|
531
|
+
Sessions inherit your rc: before cloning or launching, luv asks the remote's
|
|
532
|
+
login shell (`$SHELL -lic`) for its environment and fills in whatever tmux
|
|
533
|
+
didn't pass on, `PATH` included. So this means the var isn't exported by the
|
|
534
|
+
*remote* user's `~/.bashrc`/`~/.zshrc` — check with
|
|
535
|
+
`ssh box 'zsh -lic "printenv | cut -d= -f1"'` — or that the rc takes longer than
|
|
536
|
+
15 seconds, or that `shell_env` is `false` there. `-e` with a `LUV_`-prefixed
|
|
537
|
+
variable forwards one from your laptop regardless. See
|
|
538
|
+
[configuration.md](configuration.md#shell_env).
|
|
539
|
+
|
|
540
|
+
Note that this happens *inside* the remote luv, so it can't help ssh find `luv`
|
|
541
|
+
itself — that's the `PATH` problem above.
|
|
542
|
+
|
|
524
543
|
**My prompt didn't get sent**
|
|
525
544
|
Reopening a workspace that already has a live session attaches to it, and tmux
|
|
526
545
|
discards the command — so the prompt is dropped. luv prints a note when this can
|
|
@@ -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:
|
|
@@ -57,13 +58,13 @@ def die(msg: str) -> None:
|
|
|
57
58
|
sys.exit(1)
|
|
58
59
|
|
|
59
60
|
|
|
60
|
-
def run(cmd: list[str], *, cwd: str | None = None,
|
|
61
|
-
|
|
61
|
+
def run(cmd: list[str], *, cwd: str | None = None, timeout: float | None = None,
|
|
62
|
+
stdin: int | None = None) -> subprocess.CompletedProcess:
|
|
62
63
|
"""Capture a subprocess. A timeout surfaces as a failure, not an exception,
|
|
63
64
|
so callers keep their plain returncode checks."""
|
|
64
65
|
try:
|
|
65
66
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd,
|
|
66
|
-
timeout=timeout)
|
|
67
|
+
timeout=timeout, stdin=stdin)
|
|
67
68
|
except subprocess.TimeoutExpired:
|
|
68
69
|
return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
|
|
69
70
|
|
|
@@ -743,6 +744,84 @@ def collect_luv_env() -> dict[str, str]:
|
|
|
743
744
|
return result
|
|
744
745
|
|
|
745
746
|
|
|
747
|
+
# An rc file is a program, and some of them never finish: one that starts tmux,
|
|
748
|
+
# or waits on a prompt, would otherwise hang every session start.
|
|
749
|
+
SHELL_ENV_TIMEOUT = 15
|
|
750
|
+
|
|
751
|
+
# Vars that describe the shell we just ran rather than the setup we asked it
|
|
752
|
+
# about. Importing them would tell the agent it is somewhere it is not.
|
|
753
|
+
SHELL_ENV_SKIP = {"_", "OLDPWD", "PWD", "SHLVL"}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def shell_env() -> dict[str, str]:
|
|
757
|
+
"""Everything the user's login+interactive shell exports.
|
|
758
|
+
|
|
759
|
+
tmux and ssh exec their command directly, with no shell in between, so a
|
|
760
|
+
session luv started — a remote dispatch, a handover, a detached start —
|
|
761
|
+
never sources ~/.bashrc or ~/.zshrc. It runs with whatever environment the
|
|
762
|
+
tmux server was started with, frozen at whenever that server first came up.
|
|
763
|
+
The API key an agent authenticates with and the PATH entry that makes
|
|
764
|
+
'codex' resolvable both live in the rc, and both go missing.
|
|
765
|
+
|
|
766
|
+
Asking $SHELL for its own environment is the only way to get them back: rc
|
|
767
|
+
files are code, not data, and there is no reading them from outside. -l
|
|
768
|
+
covers the profile files, -i the rc files; between them they reach where
|
|
769
|
+
bash and zsh users actually put their exports.
|
|
770
|
+
"""
|
|
771
|
+
shell = os.environ.get("SHELL") or "/bin/bash"
|
|
772
|
+
marker = "__luv_env__"
|
|
773
|
+
code = ("import json,os,sys;"
|
|
774
|
+
f"sys.stdout.write({marker!r} + json.dumps(dict(os.environ)))")
|
|
775
|
+
probe = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
|
|
776
|
+
# DEVNULL because an interactive shell sharing our tty would fight the
|
|
777
|
+
# agent for it, and lose to SIGTTOU rather than to the timeout.
|
|
778
|
+
r = run([shell, "-lic", probe], timeout=SHELL_ENV_TIMEOUT,
|
|
779
|
+
stdin=subprocess.DEVNULL)
|
|
780
|
+
# Match on the marker, not the exit status: rc files greet, warn, and fail
|
|
781
|
+
# in ways that say nothing about whether the environment came back. What
|
|
782
|
+
# precedes the marker is the greeting; raw_decode drops anything an exit
|
|
783
|
+
# hook prints after it.
|
|
784
|
+
if marker not in r.stdout:
|
|
785
|
+
return {}
|
|
786
|
+
try:
|
|
787
|
+
env, _ = json.JSONDecoder().raw_decode(r.stdout.split(marker, 1)[1].lstrip())
|
|
788
|
+
except ValueError:
|
|
789
|
+
return {}
|
|
790
|
+
return {k: v for k, v in env.items() if isinstance(v, str)}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def merged_path(current: str, from_rc: str) -> str:
|
|
794
|
+
"""`current` first, then whatever the rc adds. PATH order is a precedence
|
|
795
|
+
list, so the binaries that got us this far keep winning."""
|
|
796
|
+
entries = [p for p in current.split(os.pathsep) if p]
|
|
797
|
+
entries += [p for p in from_rc.split(os.pathsep) if p and p not in entries]
|
|
798
|
+
return os.pathsep.join(entries)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def apply_shell_env() -> None:
|
|
802
|
+
"""Fill this process's environment in from the user's shell.
|
|
803
|
+
|
|
804
|
+
Only for the luv that tmux or ssh started (_LUV_INNER). A luv you ran
|
|
805
|
+
yourself already inherited the shell it ran in; re-deriving it there would
|
|
806
|
+
charge every session start for an rc that has already run.
|
|
807
|
+
|
|
808
|
+
What is already set wins. 'FOO=bar luv …' and -e are statements about this
|
|
809
|
+
session, while the rc is the background default, and a session that quietly
|
|
810
|
+
overrode the value you handed it would be worse than one missing it. PATH
|
|
811
|
+
is the exception, being a list rather than a value: the rc's entries are
|
|
812
|
+
appended, which is how nvm's node and ~/.local/bin become findable.
|
|
813
|
+
"""
|
|
814
|
+
if not os.environ.get("_LUV_INNER") or load_config().get("shell_env") is False:
|
|
815
|
+
return
|
|
816
|
+
for key, value in shell_env().items():
|
|
817
|
+
if key in SHELL_ENV_SKIP:
|
|
818
|
+
continue
|
|
819
|
+
if key == "PATH":
|
|
820
|
+
os.environ["PATH"] = merged_path(os.environ.get("PATH", ""), value)
|
|
821
|
+
elif key not in os.environ:
|
|
822
|
+
os.environ[key] = value
|
|
823
|
+
|
|
824
|
+
|
|
746
825
|
def docker_env_flags(env_vars: dict[str, str]) -> list[str]:
|
|
747
826
|
"""Convert env dict to docker compose exec -e flags."""
|
|
748
827
|
flags: list[str] = []
|
|
@@ -1602,6 +1681,19 @@ def continue_hint(repo: str | None, workspace: str | None = None) -> str:
|
|
|
1602
1681
|
return " ".join(parts)
|
|
1603
1682
|
|
|
1604
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
|
+
|
|
1605
1697
|
def reopen_hint(args: list[str]) -> str:
|
|
1606
1698
|
"""The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
|
|
1607
1699
|
|
|
@@ -1614,7 +1706,7 @@ def reopen_hint(args: list[str]) -> str:
|
|
|
1614
1706
|
|
|
1615
1707
|
|
|
1616
1708
|
def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
1617
|
-
hint: str | None = None) -> None:
|
|
1709
|
+
hint: str | Callable[[], str] | None = None) -> None:
|
|
1618
1710
|
"""Give the terminal to a child and exit with its status. Never returns.
|
|
1619
1711
|
|
|
1620
1712
|
Without `restore` this is a plain execv, which is the better deal when
|
|
@@ -1638,7 +1730,9 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
|
1638
1730
|
is either a detach or the agent finishing, and neither wants advice; a bad
|
|
1639
1731
|
one is usually ssh losing the connection out from under a session that is
|
|
1640
1732
|
still running on the other side, and the terminal it lands back in should
|
|
1641
|
-
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.
|
|
1642
1736
|
"""
|
|
1643
1737
|
if not restore:
|
|
1644
1738
|
os.execv(argv[0], argv)
|
|
@@ -1656,15 +1750,16 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
|
1656
1750
|
# for itself what to do with it; outliving it is the point.
|
|
1657
1751
|
continue
|
|
1658
1752
|
if code != 0 and hint:
|
|
1753
|
+
line = hint() if callable(hint) else hint
|
|
1659
1754
|
# After the guard, not inside it: a terminal still in the remote
|
|
1660
1755
|
# program's modes is no place to print something to be copied.
|
|
1661
1756
|
print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
|
|
1662
|
-
f" {
|
|
1757
|
+
f" {line}\n", file=sys.stderr)
|
|
1663
1758
|
sys.exit(code)
|
|
1664
1759
|
|
|
1665
1760
|
|
|
1666
1761
|
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
|
|
1667
|
-
hint: str | None = None) -> None:
|
|
1762
|
+
hint: str | Callable[[], str] | None = None) -> None:
|
|
1668
1763
|
"""Hand the terminal to ssh. Never returns."""
|
|
1669
1764
|
ssh_bin = shutil.which("ssh")
|
|
1670
1765
|
if not ssh_bin:
|
|
@@ -1673,7 +1768,8 @@ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
|
|
|
1673
1768
|
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
|
|
1674
1769
|
|
|
1675
1770
|
|
|
1676
|
-
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:
|
|
1677
1773
|
"""Attach to a tmux session, locally or over ssh. Never returns.
|
|
1678
1774
|
|
|
1679
1775
|
-d detaches other clients so the pane isn't size-clamped to a stale window
|
|
@@ -1801,7 +1897,8 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
|
|
|
1801
1897
|
|
|
1802
1898
|
# Only a tmux session is there to come back to; -nit and --clean run to
|
|
1803
1899
|
# completion over ssh and have nothing to continue.
|
|
1804
|
-
|
|
1900
|
+
repo = (meta or {}).get("repo")
|
|
1901
|
+
hint = continue_hint(repo, workspace) if use_tmux else None
|
|
1805
1902
|
print(f"luv: {hc['host']} — {session or 'no tmux'}")
|
|
1806
1903
|
if detach:
|
|
1807
1904
|
r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
|
|
@@ -1809,6 +1906,16 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
|
|
|
1809
1906
|
die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
|
|
1810
1907
|
print(f"luv: started detached — attach with: {hint or 'luv continue'}")
|
|
1811
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
|
|
1812
1919
|
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
|
|
1813
1920
|
hint=hint)
|
|
1814
1921
|
|
|
@@ -3457,6 +3564,11 @@ def main() -> None:
|
|
|
3457
3564
|
die("--local cannot be combined with -s or -i")
|
|
3458
3565
|
|
|
3459
3566
|
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe", "--claude", "--codex", "--local", "--no-agent-state", "--no-attach", "--purge", "-y")]
|
|
3567
|
+
|
|
3568
|
+
# Before anything shells out: the clone needs gh and git on PATH as much as
|
|
3569
|
+
# the agent needs its credentials, and both come from the same rc. Ahead of
|
|
3570
|
+
# collect_luv_env() too, so a LUV_* exported there is one -e can forward.
|
|
3571
|
+
apply_shell_env()
|
|
3460
3572
|
extra_env = collect_luv_env() if env_mode else {}
|
|
3461
3573
|
|
|
3462
3574
|
# _LUV_INNER marks the remote-side luv: it must never dispatch onward.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.
|
|
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}"),
|
|
@@ -812,6 +860,102 @@ class ConfigTests(unittest.TestCase):
|
|
|
812
860
|
self.assertIn("ConnectTimeout=5", base)
|
|
813
861
|
|
|
814
862
|
|
|
863
|
+
def _shell_env_output(env, before="", after=""):
|
|
864
|
+
"""What the probe sees: an rc's greeting, the marker, the environment."""
|
|
865
|
+
return f"{before}__luv_env__{json.dumps(env)}{after}"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
class ShellEnvTests(unittest.TestCase):
|
|
869
|
+
"""tmux and ssh exec without a shell, so the rc has to be asked for."""
|
|
870
|
+
|
|
871
|
+
def _probe(self, stdout, returncode=0):
|
|
872
|
+
with (patch.dict(luv.os.environ, {"SHELL": "/bin/zsh"}, clear=True),
|
|
873
|
+
patch.object(luv, "run",
|
|
874
|
+
return_value=_completed(stdout, returncode)) as run):
|
|
875
|
+
return luv.shell_env(), run.call_args
|
|
876
|
+
|
|
877
|
+
def test_asks_the_users_own_shell_as_login_and_interactive(self):
|
|
878
|
+
env, call = self._probe(_shell_env_output({"API_BASE": "https://x"}))
|
|
879
|
+
|
|
880
|
+
self.assertEqual(env, {"API_BASE": "https://x"})
|
|
881
|
+
self.assertEqual(call.args[0][:2], ["/bin/zsh", "-lic"])
|
|
882
|
+
self.assertEqual(call.kwargs["stdin"], subprocess.DEVNULL)
|
|
883
|
+
self.assertEqual(call.kwargs["timeout"], luv.SHELL_ENV_TIMEOUT)
|
|
884
|
+
|
|
885
|
+
def test_greeting_before_the_marker_is_ignored(self):
|
|
886
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}, before="welcome back!\n"))
|
|
887
|
+
|
|
888
|
+
self.assertEqual(env, {"A": "1"})
|
|
889
|
+
|
|
890
|
+
def test_exit_hook_after_the_environment_is_ignored(self):
|
|
891
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}, after="\ngoodbye\n"))
|
|
892
|
+
|
|
893
|
+
self.assertEqual(env, {"A": "1"})
|
|
894
|
+
|
|
895
|
+
def test_failing_rc_still_yields_what_it_exported(self):
|
|
896
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}), returncode=1)
|
|
897
|
+
|
|
898
|
+
self.assertEqual(env, {"A": "1"})
|
|
899
|
+
|
|
900
|
+
def test_a_shell_that_says_nothing_useful_is_not_an_error(self):
|
|
901
|
+
self.assertEqual(self._probe("")[0], {})
|
|
902
|
+
self.assertEqual(self._probe("__luv_env__not json")[0], {})
|
|
903
|
+
|
|
904
|
+
def test_timeout_is_not_an_error(self):
|
|
905
|
+
# run() turns a hung rc into returncode 124 rather than an exception.
|
|
906
|
+
self.assertEqual(self._probe("", returncode=124)[0], {})
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
class ApplyShellEnvTests(unittest.TestCase):
|
|
910
|
+
def _apply(self, environ, rc, config=None):
|
|
911
|
+
with (patch.dict(luv.os.environ, environ, clear=True),
|
|
912
|
+
patch.object(luv, "load_config", return_value=config or {}),
|
|
913
|
+
patch.object(luv, "shell_env", return_value=rc) as probe):
|
|
914
|
+
luv.apply_shell_env()
|
|
915
|
+
return dict(luv.os.environ), probe.called
|
|
916
|
+
|
|
917
|
+
def test_only_runs_for_the_luv_tmux_or_ssh_started(self):
|
|
918
|
+
env, probed = self._apply({}, {"API_KEY": "from-rc"})
|
|
919
|
+
|
|
920
|
+
self.assertFalse(probed)
|
|
921
|
+
self.assertNotIn("API_KEY", env)
|
|
922
|
+
|
|
923
|
+
def test_fills_in_what_the_session_never_sourced(self):
|
|
924
|
+
env, _ = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"})
|
|
925
|
+
|
|
926
|
+
self.assertEqual(env["API_KEY"], "from-rc")
|
|
927
|
+
|
|
928
|
+
def test_what_the_caller_set_wins(self):
|
|
929
|
+
env, _ = self._apply({"_LUV_INNER": "1", "API_KEY": "explicit"},
|
|
930
|
+
{"API_KEY": "from-rc", "OTHER": "from-rc"})
|
|
931
|
+
|
|
932
|
+
self.assertEqual(env["API_KEY"], "explicit")
|
|
933
|
+
self.assertEqual(env["OTHER"], "from-rc")
|
|
934
|
+
|
|
935
|
+
def test_path_is_merged_rather_than_replaced(self):
|
|
936
|
+
env, _ = self._apply({"_LUV_INNER": "1", "PATH": "/usr/bin:/bin"},
|
|
937
|
+
{"PATH": "/home/u/.nvm/bin:/usr/bin:/home/u/.local/bin"})
|
|
938
|
+
|
|
939
|
+
self.assertEqual(env["PATH"],
|
|
940
|
+
"/usr/bin:/bin:/home/u/.nvm/bin:/home/u/.local/bin")
|
|
941
|
+
|
|
942
|
+
def test_the_probe_shells_own_bookkeeping_is_left_behind(self):
|
|
943
|
+
env, _ = self._apply({"_LUV_INNER": "1"},
|
|
944
|
+
{"PWD": "/home/u", "SHLVL": "3", "_": "/usr/bin/env",
|
|
945
|
+
"OLDPWD": "/tmp", "KEEP": "yes"})
|
|
946
|
+
|
|
947
|
+
self.assertEqual(env["KEEP"], "yes")
|
|
948
|
+
for key in ("PWD", "SHLVL", "_", "OLDPWD"):
|
|
949
|
+
self.assertNotIn(key, env)
|
|
950
|
+
|
|
951
|
+
def test_config_can_turn_it_off(self):
|
|
952
|
+
env, probed = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"},
|
|
953
|
+
config={"shell_env": False})
|
|
954
|
+
|
|
955
|
+
self.assertFalse(probed)
|
|
956
|
+
self.assertNotIn("API_KEY", env)
|
|
957
|
+
|
|
958
|
+
|
|
815
959
|
class CleanGuardTests(unittest.TestCase):
|
|
816
960
|
def test_live_session_folder_is_skipped(self):
|
|
817
961
|
tempdir = tempfile.TemporaryDirectory()
|
|
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
|