luv-cli 0.5.1__tar.gz → 0.6.0__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.0
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
@@ -521,6 +521,19 @@ The key isn't installed or isn't being offered. Test with `ssh -v box`. Check
521
521
  `gh` is authenticated on your laptop but not on the remote. luv numbers
522
522
  workspaces with `gh api` *on the remote*, so it needs its own login there.
523
523
 
524
+ **The agent can't find an API key / a tool it needs is missing**
525
+ Sessions inherit your rc: before cloning or launching, luv asks the remote's
526
+ login shell (`$SHELL -lic`) for its environment and fills in whatever tmux
527
+ didn't pass on, `PATH` included. So this means the var isn't exported by the
528
+ *remote* user's `~/.bashrc`/`~/.zshrc` — check with
529
+ `ssh box 'zsh -lic "printenv | cut -d= -f1"'` — or that the rc takes longer than
530
+ 15 seconds, or that `shell_env` is `false` there. `-e` with a `LUV_`-prefixed
531
+ variable forwards one from your laptop regardless. See
532
+ [configuration.md](configuration.md#shell_env).
533
+
534
+ Note that this happens *inside* the remote luv, so it can't help ssh find `luv`
535
+ itself — that's the `PATH` problem above.
536
+
524
537
  **My prompt didn't get sent**
525
538
  Reopening a workspace that already has a live session attaches to it, and tmux
526
539
  discards the command — so the prompt is dropped. luv prints a note when this can
@@ -57,13 +57,13 @@ def die(msg: str) -> None:
57
57
  sys.exit(1)
58
58
 
59
59
 
60
- def run(cmd: list[str], *, cwd: str | None = None,
61
- timeout: float | None = None) -> subprocess.CompletedProcess:
60
+ def run(cmd: list[str], *, cwd: str | None = None, timeout: float | None = None,
61
+ stdin: int | None = None) -> subprocess.CompletedProcess:
62
62
  """Capture a subprocess. A timeout surfaces as a failure, not an exception,
63
63
  so callers keep their plain returncode checks."""
64
64
  try:
65
65
  return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd,
66
- timeout=timeout)
66
+ timeout=timeout, stdin=stdin)
67
67
  except subprocess.TimeoutExpired:
68
68
  return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
69
69
 
@@ -743,6 +743,84 @@ def collect_luv_env() -> dict[str, str]:
743
743
  return result
744
744
 
745
745
 
746
+ # An rc file is a program, and some of them never finish: one that starts tmux,
747
+ # or waits on a prompt, would otherwise hang every session start.
748
+ SHELL_ENV_TIMEOUT = 15
749
+
750
+ # Vars that describe the shell we just ran rather than the setup we asked it
751
+ # about. Importing them would tell the agent it is somewhere it is not.
752
+ SHELL_ENV_SKIP = {"_", "OLDPWD", "PWD", "SHLVL"}
753
+
754
+
755
+ def shell_env() -> dict[str, str]:
756
+ """Everything the user's login+interactive shell exports.
757
+
758
+ tmux and ssh exec their command directly, with no shell in between, so a
759
+ session luv started — a remote dispatch, a handover, a detached start —
760
+ never sources ~/.bashrc or ~/.zshrc. It runs with whatever environment the
761
+ tmux server was started with, frozen at whenever that server first came up.
762
+ The API key an agent authenticates with and the PATH entry that makes
763
+ 'codex' resolvable both live in the rc, and both go missing.
764
+
765
+ Asking $SHELL for its own environment is the only way to get them back: rc
766
+ files are code, not data, and there is no reading them from outside. -l
767
+ covers the profile files, -i the rc files; between them they reach where
768
+ bash and zsh users actually put their exports.
769
+ """
770
+ shell = os.environ.get("SHELL") or "/bin/bash"
771
+ marker = "__luv_env__"
772
+ code = ("import json,os,sys;"
773
+ f"sys.stdout.write({marker!r} + json.dumps(dict(os.environ)))")
774
+ probe = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
775
+ # DEVNULL because an interactive shell sharing our tty would fight the
776
+ # agent for it, and lose to SIGTTOU rather than to the timeout.
777
+ r = run([shell, "-lic", probe], timeout=SHELL_ENV_TIMEOUT,
778
+ stdin=subprocess.DEVNULL)
779
+ # Match on the marker, not the exit status: rc files greet, warn, and fail
780
+ # in ways that say nothing about whether the environment came back. What
781
+ # precedes the marker is the greeting; raw_decode drops anything an exit
782
+ # hook prints after it.
783
+ if marker not in r.stdout:
784
+ return {}
785
+ try:
786
+ env, _ = json.JSONDecoder().raw_decode(r.stdout.split(marker, 1)[1].lstrip())
787
+ except ValueError:
788
+ return {}
789
+ return {k: v for k, v in env.items() if isinstance(v, str)}
790
+
791
+
792
+ def merged_path(current: str, from_rc: str) -> str:
793
+ """`current` first, then whatever the rc adds. PATH order is a precedence
794
+ list, so the binaries that got us this far keep winning."""
795
+ entries = [p for p in current.split(os.pathsep) if p]
796
+ entries += [p for p in from_rc.split(os.pathsep) if p and p not in entries]
797
+ return os.pathsep.join(entries)
798
+
799
+
800
+ def apply_shell_env() -> None:
801
+ """Fill this process's environment in from the user's shell.
802
+
803
+ Only for the luv that tmux or ssh started (_LUV_INNER). A luv you ran
804
+ yourself already inherited the shell it ran in; re-deriving it there would
805
+ charge every session start for an rc that has already run.
806
+
807
+ What is already set wins. 'FOO=bar luv …' and -e are statements about this
808
+ session, while the rc is the background default, and a session that quietly
809
+ overrode the value you handed it would be worse than one missing it. PATH
810
+ is the exception, being a list rather than a value: the rc's entries are
811
+ appended, which is how nvm's node and ~/.local/bin become findable.
812
+ """
813
+ if not os.environ.get("_LUV_INNER") or load_config().get("shell_env") is False:
814
+ return
815
+ for key, value in shell_env().items():
816
+ if key in SHELL_ENV_SKIP:
817
+ continue
818
+ if key == "PATH":
819
+ os.environ["PATH"] = merged_path(os.environ.get("PATH", ""), value)
820
+ elif key not in os.environ:
821
+ os.environ[key] = value
822
+
823
+
746
824
  def docker_env_flags(env_vars: dict[str, str]) -> list[str]:
747
825
  """Convert env dict to docker compose exec -e flags."""
748
826
  flags: list[str] = []
@@ -3457,6 +3535,11 @@ def main() -> None:
3457
3535
  die("--local cannot be combined with -s or -i")
3458
3536
 
3459
3537
  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")]
3538
+
3539
+ # Before anything shells out: the clone needs gh and git on PATH as much as
3540
+ # the agent needs its credentials, and both come from the same rc. Ahead of
3541
+ # collect_luv_env() too, so a LUV_* exported there is one -e can forward.
3542
+ apply_shell_env()
3460
3543
  extra_env = collect_luv_env() if env_mode else {}
3461
3544
 
3462
3545
  # _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.0"
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"
@@ -812,6 +812,102 @@ class ConfigTests(unittest.TestCase):
812
812
  self.assertIn("ConnectTimeout=5", base)
813
813
 
814
814
 
815
+ def _shell_env_output(env, before="", after=""):
816
+ """What the probe sees: an rc's greeting, the marker, the environment."""
817
+ return f"{before}__luv_env__{json.dumps(env)}{after}"
818
+
819
+
820
+ class ShellEnvTests(unittest.TestCase):
821
+ """tmux and ssh exec without a shell, so the rc has to be asked for."""
822
+
823
+ def _probe(self, stdout, returncode=0):
824
+ with (patch.dict(luv.os.environ, {"SHELL": "/bin/zsh"}, clear=True),
825
+ patch.object(luv, "run",
826
+ return_value=_completed(stdout, returncode)) as run):
827
+ return luv.shell_env(), run.call_args
828
+
829
+ def test_asks_the_users_own_shell_as_login_and_interactive(self):
830
+ env, call = self._probe(_shell_env_output({"API_BASE": "https://x"}))
831
+
832
+ self.assertEqual(env, {"API_BASE": "https://x"})
833
+ self.assertEqual(call.args[0][:2], ["/bin/zsh", "-lic"])
834
+ self.assertEqual(call.kwargs["stdin"], subprocess.DEVNULL)
835
+ self.assertEqual(call.kwargs["timeout"], luv.SHELL_ENV_TIMEOUT)
836
+
837
+ def test_greeting_before_the_marker_is_ignored(self):
838
+ env, _ = self._probe(_shell_env_output({"A": "1"}, before="welcome back!\n"))
839
+
840
+ self.assertEqual(env, {"A": "1"})
841
+
842
+ def test_exit_hook_after_the_environment_is_ignored(self):
843
+ env, _ = self._probe(_shell_env_output({"A": "1"}, after="\ngoodbye\n"))
844
+
845
+ self.assertEqual(env, {"A": "1"})
846
+
847
+ def test_failing_rc_still_yields_what_it_exported(self):
848
+ env, _ = self._probe(_shell_env_output({"A": "1"}), returncode=1)
849
+
850
+ self.assertEqual(env, {"A": "1"})
851
+
852
+ def test_a_shell_that_says_nothing_useful_is_not_an_error(self):
853
+ self.assertEqual(self._probe("")[0], {})
854
+ self.assertEqual(self._probe("__luv_env__not json")[0], {})
855
+
856
+ def test_timeout_is_not_an_error(self):
857
+ # run() turns a hung rc into returncode 124 rather than an exception.
858
+ self.assertEqual(self._probe("", returncode=124)[0], {})
859
+
860
+
861
+ class ApplyShellEnvTests(unittest.TestCase):
862
+ def _apply(self, environ, rc, config=None):
863
+ with (patch.dict(luv.os.environ, environ, clear=True),
864
+ patch.object(luv, "load_config", return_value=config or {}),
865
+ patch.object(luv, "shell_env", return_value=rc) as probe):
866
+ luv.apply_shell_env()
867
+ return dict(luv.os.environ), probe.called
868
+
869
+ def test_only_runs_for_the_luv_tmux_or_ssh_started(self):
870
+ env, probed = self._apply({}, {"API_KEY": "from-rc"})
871
+
872
+ self.assertFalse(probed)
873
+ self.assertNotIn("API_KEY", env)
874
+
875
+ def test_fills_in_what_the_session_never_sourced(self):
876
+ env, _ = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"})
877
+
878
+ self.assertEqual(env["API_KEY"], "from-rc")
879
+
880
+ def test_what_the_caller_set_wins(self):
881
+ env, _ = self._apply({"_LUV_INNER": "1", "API_KEY": "explicit"},
882
+ {"API_KEY": "from-rc", "OTHER": "from-rc"})
883
+
884
+ self.assertEqual(env["API_KEY"], "explicit")
885
+ self.assertEqual(env["OTHER"], "from-rc")
886
+
887
+ def test_path_is_merged_rather_than_replaced(self):
888
+ env, _ = self._apply({"_LUV_INNER": "1", "PATH": "/usr/bin:/bin"},
889
+ {"PATH": "/home/u/.nvm/bin:/usr/bin:/home/u/.local/bin"})
890
+
891
+ self.assertEqual(env["PATH"],
892
+ "/usr/bin:/bin:/home/u/.nvm/bin:/home/u/.local/bin")
893
+
894
+ def test_the_probe_shells_own_bookkeeping_is_left_behind(self):
895
+ env, _ = self._apply({"_LUV_INNER": "1"},
896
+ {"PWD": "/home/u", "SHLVL": "3", "_": "/usr/bin/env",
897
+ "OLDPWD": "/tmp", "KEEP": "yes"})
898
+
899
+ self.assertEqual(env["KEEP"], "yes")
900
+ for key in ("PWD", "SHLVL", "_", "OLDPWD"):
901
+ self.assertNotIn(key, env)
902
+
903
+ def test_config_can_turn_it_off(self):
904
+ env, probed = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"},
905
+ config={"shell_env": False})
906
+
907
+ self.assertFalse(probed)
908
+ self.assertNotIn("API_KEY", env)
909
+
910
+
815
911
  class CleanGuardTests(unittest.TestCase):
816
912
  def test_live_session_folder_is_skipped(self):
817
913
  tempdir = tempfile.TemporaryDirectory()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes