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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.5.1
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
- timeout: float | None = None) -> subprocess.CompletedProcess:
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" {hint}\n", file=sys.stderr)
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, *, hint: str | None = None) -> None:
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
- hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
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.5.1"
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