luv-cli 0.6.1__tar.gz → 0.6.3__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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: luv-cli
3
- Version: 0.6.1
3
+ Version: 0.6.3
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
@@ -89,7 +89,7 @@ luv continue
89
89
  1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
90
90
  2. Creates a new branch `luv-{machine}-{number}`
91
91
  3. Configures the selected agent with the workspace's PR conventions
92
- 4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
92
+ 4. Launches Claude with Opus 5.5 at max effort, or Codex in YOLO mode
93
93
 
94
94
  All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter, and `{machine}` is a short name for the machine that created the workspace — by default the hostname, or whatever you set with `luv config set machine mbp`.
95
95
 
@@ -149,7 +149,7 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
149
149
  | `-r` | Resume: resume the selected agent's last session |
150
150
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
151
151
  | `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
152
- | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5`, while Codex uses its configured CLI default |
152
+ | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5-5`, while Codex uses its configured CLI default |
153
153
  | `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
154
154
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
155
155
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
@@ -66,7 +66,7 @@ luv continue
66
66
  1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
67
67
  2. Creates a new branch `luv-{machine}-{number}`
68
68
  3. Configures the selected agent with the workspace's PR conventions
69
- 4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
69
+ 4. Launches Claude with Opus 5.5 at max effort, or Codex in YOLO mode
70
70
 
71
71
  All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter, and `{machine}` is a short name for the machine that created the workspace — by default the hostname, or whatever you set with `luv config set machine mbp`.
72
72
 
@@ -126,7 +126,7 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
126
126
  | `-r` | Resume: resume the selected agent's last session |
127
127
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
128
128
  | `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
129
- | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5`, while Codex uses its configured CLI default |
129
+ | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5-5`, while Codex uses its configured CLI default |
130
130
  | `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
131
131
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
132
132
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
@@ -59,12 +59,14 @@ def die(msg: str) -> None:
59
59
 
60
60
 
61
61
  def run(cmd: list[str], *, cwd: str | None = None, timeout: float | None = None,
62
- stdin: int | None = None) -> subprocess.CompletedProcess:
62
+ stdin: int | None = None,
63
+ new_session: bool = False) -> subprocess.CompletedProcess:
63
64
  """Capture a subprocess. A timeout surfaces as a failure, not an exception,
64
65
  so callers keep their plain returncode checks."""
65
66
  try:
66
67
  return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd,
67
- timeout=timeout, stdin=stdin)
68
+ timeout=timeout, stdin=stdin,
69
+ start_new_session=new_session)
68
70
  except subprocess.TimeoutExpired:
69
71
  return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
70
72
 
@@ -773,10 +775,13 @@ def shell_env() -> dict[str, str]:
773
775
  code = ("import json,os,sys;"
774
776
  f"sys.stdout.write({marker!r} + json.dumps(dict(os.environ)))")
775
777
  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
+ # A session of its own, so the probe has no controlling tty to touch. An
779
+ # interactive zsh opens /dev/tty whatever stdin is and makes its own process
780
+ # group the foreground one, then execs the probe rather than forking it, so
781
+ # nothing is left to hand the terminal back. luv and the agent it execs would
782
+ # be in the background, and the agent's first tcsetattr gets SIGTTOU.
778
783
  r = run([shell, "-lic", probe], timeout=SHELL_ENV_TIMEOUT,
779
- stdin=subprocess.DEVNULL)
784
+ stdin=subprocess.DEVNULL, new_session=True)
780
785
  # Match on the marker, not the exit status: rc files greet, warn, and fail
781
786
  # in ways that say nothing about whether the environment came back. What
782
787
  # precedes the marker is the greeting; raw_decode drops anything an exit
@@ -1204,7 +1209,7 @@ def resume(clone_dir: Path, extra_env: dict[str, str] | None = None,
1204
1209
 
1205
1210
  if agent == "claude":
1206
1211
  agent_cmd = ["claude", "--dangerously-skip-permissions",
1207
- "--model", model or "claude-opus-5",
1212
+ "--model", model or "claude-opus-5-5",
1208
1213
  "--effort", "max", "--resume",
1209
1214
  "--remote-control",
1210
1215
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -1260,7 +1265,7 @@ def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
1260
1265
  agent_cmd.append(prompt)
1261
1266
  else:
1262
1267
  common_flags = ["--dangerously-skip-permissions",
1263
- "--model", model or "claude-opus-5",
1268
+ "--model", model or "claude-opus-5-5",
1264
1269
  "--effort", "max",
1265
1270
  "--remote-control",
1266
1271
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -3586,7 +3591,7 @@ Flags:
3586
3591
  -r resume: resume the selected agent's last session
3587
3592
  -p launch Claude in plan permission mode (default: bypassPermissions)
3588
3593
  -nit non-interactive: run the selected agent and exit (no REPL)
3589
- -m MODEL model to use (Claude default: claude-opus-5; Codex: CLI default)
3594
+ -m MODEL model to use (Claude default: claude-opus-5-5; Codex: CLI default)
3590
3595
  -b BRANCH base a new workspace off BRANCH (clone + branch from it; recorded in git config luv.base)
3591
3596
  -e env: pass LUV_* environment variables (with prefix stripped) into the session
3592
3597
  -s HOST run on HOST over SSH (overrides the configured remote host)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "luv-cli"
7
- version = "0.6.1"
7
+ version = "0.6.3"
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"
@@ -1,7 +1,10 @@
1
1
  import contextlib
2
2
  import io
3
3
  import json
4
+ import os
5
+ import pty
4
6
  import re
7
+ import shutil
5
8
  import subprocess
6
9
  import tempfile
7
10
  import time
@@ -66,7 +69,7 @@ class AgentLaunchTests(unittest.TestCase):
66
69
  _, argv = self._exec_args(luv.launch, self.clone_dir, None)
67
70
 
68
71
  self.assertEqual(argv[0], "/bin/claude")
69
- self.assertIn("claude-opus-5", argv)
72
+ self.assertIn("claude-opus-5-5", argv)
70
73
  self.assertIn("--dangerously-skip-permissions", argv)
71
74
 
72
75
  def test_main_routes_codex_to_existing_workspace(self):
@@ -880,8 +883,33 @@ class ShellEnvTests(unittest.TestCase):
880
883
  self.assertEqual(env, {"API_BASE": "https://x"})
881
884
  self.assertEqual(call.args[0][:2], ["/bin/zsh", "-lic"])
882
885
  self.assertEqual(call.kwargs["stdin"], subprocess.DEVNULL)
886
+ self.assertTrue(call.kwargs["new_session"])
883
887
  self.assertEqual(call.kwargs["timeout"], luv.SHELL_ENV_TIMEOUT)
884
888
 
889
+ @unittest.skipUnless(shutil.which("zsh"), "needs zsh")
890
+ def test_probe_leaves_the_terminal_in_the_foreground_of_the_caller(self):
891
+ # A real zsh on a real pty, because only that shows the failure: zsh
892
+ # takes the foreground and execs the probe, so the caller, and the agent
893
+ # it later execs, would be stopped by SIGTTOU on touching the terminal.
894
+ with tempfile.TemporaryDirectory() as zdotdir:
895
+ pid, fd = pty.fork()
896
+ if pid == 0:
897
+ status = 1
898
+ try:
899
+ with patch.dict(luv.os.environ,
900
+ {"SHELL": "zsh", "ZDOTDIR": zdotdir}):
901
+ luv.shell_env()
902
+ status = 0 if os.tcgetpgrp(0) == os.getpgrp() else 1
903
+ finally:
904
+ os._exit(status)
905
+ with contextlib.suppress(OSError):
906
+ while os.read(fd, 4096):
907
+ pass
908
+ os.close(fd)
909
+ _, status = os.waitpid(pid, 0)
910
+
911
+ self.assertEqual(os.waitstatus_to_exitcode(status), 0)
912
+
885
913
  def test_greeting_before_the_marker_is_ignored(self):
886
914
  env, _ = self._probe(_shell_env_output({"A": "1"}, before="welcome back!\n"))
887
915
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes