luv-cli 0.5.0__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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * version-policies.mjs — remind the agent to bump the release version.
3
+ *
4
+ * Auto-loaded by failproofai from .failproofai/policies/ — no install step.
5
+ *
6
+ * Publishing here is driven by the version in the manifest: a push to main
7
+ * whose version has no tag yet releases itself (.github/workflows/publish.yml).
8
+ * So a branch that changes the tool and forgets the version merges into a main
9
+ * that publishes nothing, and the change sits unreleased until someone notices.
10
+ *
11
+ * So this says so on `git commit`, and only while the branch's version still
12
+ * matches the base branch's — it is context, not a block, and bumping the
13
+ * version is what turns it off for the rest of the branch.
14
+ */
15
+ import { execFileSync } from "node:child_process";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import { customPolicies, allow, instruct } from "failproofai";
20
+
21
+ // First match wins, so the order is "most likely to be the release manifest".
22
+ const MANIFESTS = [
23
+ { file: "pyproject.toml", re: /^\s*version\s*=\s*["']([^"']+)["']/m },
24
+ { file: "package.json", re: /"version"\s*:\s*"([^"]+)"/ },
25
+ { file: "Cargo.toml", re: /^\s*version\s*=\s*["']([^"']+)["']/m },
26
+ ];
27
+
28
+ const BASE_REFS = ["origin/main", "main", "origin/master", "master"];
29
+
30
+ // A `git … commit` that starts a command, so a shell segment is allowed to
31
+ // carry flags and their values (`git -C dir commit`) but a `commit` merely
32
+ // spoken about (`echo "run git commit later"`) is not mistaken for one.
33
+ const GIT_COMMIT = /(?:^|[;&|\n(]|&&|\|\|)\s*(?:\w+=\S+\s+)*git\b[^;&|\n]*?\bcommit\b/;
34
+
35
+ /** git, or null on any failure — this policy never gets in the way. */
36
+ function git(cwd, args) {
37
+ try {
38
+ return execFileSync("git", args, {
39
+ cwd,
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ timeout: 5000,
43
+ }).trim();
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function versionIn(text, re) {
50
+ const m = text == null ? null : text.match(re);
51
+ return m ? m[1] : null;
52
+ }
53
+
54
+ /** The manifest this repo releases from, with the version on both sides. */
55
+ function releaseVersions(cwd) {
56
+ const base = BASE_REFS.find((ref) =>
57
+ git(cwd, ["rev-parse", "--verify", "--quiet", ref]) !== null);
58
+ if (!base) return null;
59
+
60
+ for (const { file, re } of MANIFESTS) {
61
+ if (!existsSync(join(cwd, file))) continue;
62
+ let head;
63
+ try {
64
+ head = versionIn(readFileSync(join(cwd, file), "utf8"), re);
65
+ } catch {
66
+ return null;
67
+ }
68
+ // No version on the base side means the manifest is new on this branch;
69
+ // there is nothing to bump relative to.
70
+ const baseVersion = versionIn(git(cwd, ["show", `${base}:${file}`]), re);
71
+ if (!head || !baseVersion) return null;
72
+ return { file, base, head, baseVersion };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ customPolicies.add({
78
+ name: "remind-version-bump-on-commit",
79
+ description: "On git commit, remind that the release version is still the base branch's",
80
+ match: { events: ["PreToolUse"] },
81
+ fn: async (ctx) => {
82
+ if (ctx.toolName !== "Bash") return allow();
83
+ const cmd = String(ctx.toolInput?.command ?? "");
84
+ if (!GIT_COMMIT.test(cmd)) return allow();
85
+
86
+ const cwd = ctx.session?.cwd ?? process.cwd();
87
+
88
+ // Committing on the base branch is a different problem, and one the
89
+ // block-work-on-main policy already has an opinion about.
90
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
91
+ if (branch === null || branch === "main" || branch === "master") return allow();
92
+
93
+ const found = releaseVersions(cwd);
94
+ if (!found) return allow();
95
+ if (found.head !== found.baseVersion) return allow(); // already bumped
96
+
97
+ return instruct(
98
+ `${found.file} still says version ${found.head}, the same as ${found.base}. ` +
99
+ "Releases are cut from that version when this branch lands, so a merge " +
100
+ "with it unchanged publishes nothing. Bump it in this branch — patch for " +
101
+ "a fix or polish, minor for a feature — unless the change is docs, tests, " +
102
+ "or CI only, in which case say so and carry on."
103
+ );
104
+ },
105
+ });
106
+
107
+ export { customPolicies };
@@ -0,0 +1,117 @@
1
+ name: Publish to PyPI
2
+
3
+ # Two ways in.
4
+ #
5
+ # * A push to main publishes when the version in pyproject.toml is one no tag
6
+ # exists for yet. That is what makes a merged version bump release itself.
7
+ # * A v* tag publishes whatever it points at, kept so a release can still be
8
+ # cut by hand.
9
+ #
10
+ # The tag is created here rather than by a separate workflow because a tag
11
+ # pushed with GITHUB_TOKEN does not trigger other workflows — GitHub's recursion
12
+ # guard. Tagging elsewhere and relying on the v* trigger to fire would leave the
13
+ # tag in place and nothing published, which is the worst of both.
14
+ #
15
+ # Deliberately no `paths:` filter. It would be evaluated for tag pushes as well,
16
+ # so `paths: [pyproject.toml]` would silently drop a hand-pushed tag whose commit
17
+ # did not happen to touch that file. The "is this already tagged" check below is
18
+ # the real guard; a filter would only be an optimisation, and a lossy one.
19
+
20
+ on:
21
+ push:
22
+ tags:
23
+ - "v*"
24
+ branches:
25
+ - main
26
+
27
+ concurrency:
28
+ group: publish
29
+ cancel-in-progress: false
30
+
31
+ jobs:
32
+ decide:
33
+ runs-on: ubuntu-latest
34
+ permissions:
35
+ contents: read
36
+ outputs:
37
+ publish: ${{ steps.resolve.outputs.publish }}
38
+ version: ${{ steps.resolve.outputs.version }}
39
+ needs_tag: ${{ steps.resolve.outputs.needs_tag }}
40
+ steps:
41
+ # Its own job so the pypi environment — which may require a reviewer — is
42
+ # never entered by a run that turns out to have nothing to publish.
43
+ - uses: actions/checkout@v4
44
+ with:
45
+ fetch-depth: 0 # tags, so the guard below can see them
46
+
47
+ - uses: actions/setup-python@v5
48
+ with:
49
+ python-version: "3.12"
50
+
51
+ - name: Work out whether there is a release to make
52
+ id: resolve
53
+ run: |
54
+ set -euo pipefail
55
+
56
+ if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
57
+ version="${GITHUB_REF_NAME#v}"
58
+ needs_tag=false
59
+ else
60
+ version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
61
+ needs_tag=true
62
+ fi
63
+
64
+ if [ -z "${version}" ]; then
65
+ echo "could not determine a version to publish" >&2
66
+ exit 1
67
+ fi
68
+
69
+ if [ "${needs_tag}" = true ] && git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
70
+ echo "v${version} is already tagged — nothing to release"
71
+ echo "publish=false" >> "$GITHUB_OUTPUT"
72
+ exit 0
73
+ fi
74
+
75
+ echo "releasing ${version}"
76
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
77
+ echo "needs_tag=${needs_tag}" >> "$GITHUB_OUTPUT"
78
+ echo "publish=true" >> "$GITHUB_OUTPUT"
79
+
80
+ publish:
81
+ needs: decide
82
+ if: needs.decide.outputs.publish == 'true'
83
+ runs-on: ubuntu-latest
84
+ environment: pypi
85
+ permissions:
86
+ contents: write # push the tag a merged version bump implies
87
+ id-token: write # PyPI trusted publishing
88
+ steps:
89
+ - uses: actions/checkout@v4
90
+ with:
91
+ fetch-depth: 0
92
+
93
+ - uses: actions/setup-python@v5
94
+ with:
95
+ python-version: "3.12"
96
+
97
+ # Before the build, so a tag that cannot be pushed — protected tags, say —
98
+ # fails the run rather than publishing a version with no tag to show for it.
99
+ - name: Tag the release
100
+ if: needs.decide.outputs.needs_tag == 'true'
101
+ env:
102
+ VERSION: ${{ needs.decide.outputs.version }}
103
+ run: |
104
+ set -euo pipefail
105
+ git config user.name "github-actions[bot]"
106
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
107
+ git tag -a "v${VERSION}" -m "v${VERSION}"
108
+ git push origin "v${VERSION}"
109
+
110
+ - name: Install build tools
111
+ run: pip install build
112
+
113
+ - name: Build package
114
+ run: python -m build
115
+
116
+ - name: Publish to PyPI
117
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.5.0
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
@@ -256,6 +256,19 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
256
256
  Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
257
257
  running.
258
258
 
259
+ When a session ends on its own — the connection drops, ssh is killed, the pane
260
+ dies — luv prints the way back in, already filled in for that session:
261
+
262
+ ```
263
+ luv: session ended unexpectedly — continue it with:
264
+
265
+ luv continue myrepo 42
266
+ ```
267
+
268
+ Copy that line and run it. If the agent took the tmux session down with it,
269
+ there is nothing left to attach to; `luv continue` says so and hands you
270
+ `luv myrepo 42 -r`, which reopens the workspace and resumes the conversation.
271
+
259
272
  ## Reaching servers the agent started
260
273
 
261
274
  An agent working on the remote starts servers there — a dev server, a compose
@@ -409,7 +422,7 @@ machine with `--from` and luv will move the folder and start it fresh.
409
422
  | Step | What happens |
410
423
  |---|---|
411
424
  | `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
412
- | `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up |
425
+ | `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up. A connection that broke leaves the exact `luv continue <repo> <n>` for it in your terminal |
413
426
  | `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
414
427
  | `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
415
428
  | Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, `luv ls` prunes the entry on its next run |
@@ -508,6 +521,19 @@ The key isn't installed or isn't being offered. Test with `ssh -v box`. Check
508
521
  `gh` is authenticated on your laptop but not on the remote. luv numbers
509
522
  workspaces with `gh api` *on the remote*, so it needs its own login there.
510
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
+
511
537
  **My prompt didn't get sent**
512
538
  Reopening a workspace that already has a live session attaches to it, and tmux
513
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] = []
@@ -1585,7 +1663,36 @@ def terminal_guard():
1585
1663
  os.write(fd, TERM_RESET.encode())
1586
1664
 
1587
1665
 
1588
- def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1666
+ def continue_hint(repo: str | None, workspace: str | None = None) -> str:
1667
+ """The 'luv continue …' line to hand back when a session breaks.
1668
+
1669
+ As narrow as what we actually know, and no narrower: the number is read off
1670
+ the workspace folder, which a session dispatched before the remote picked
1671
+ one doesn't have yet. Every shorter form is still a command that works —
1672
+ a repo alone takes its newest session, and bare 'luv continue' asks.
1673
+ """
1674
+ parts = ["luv", "continue"]
1675
+ if repo:
1676
+ parts.append(repo)
1677
+ number = workspace_number(repo, workspace or "")
1678
+ if number is not None:
1679
+ parts.append(str(number))
1680
+ return " ".join(parts)
1681
+
1682
+
1683
+ def reopen_hint(args: list[str]) -> str:
1684
+ """The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
1685
+
1686
+ Takes the same [<repo> [number]] grammar 'luv continue' does, so whatever
1687
+ was just typed carries straight over to the command that still works.
1688
+ """
1689
+ repo = args[0].rstrip("/").rsplit("/", 1)[-1]
1690
+ number = args[1] if len(args) > 1 and args[1].isdigit() else None
1691
+ return " ".join(["luv", repo] + ([number] if number else []) + ["-r"])
1692
+
1693
+
1694
+ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1695
+ hint: str | None = None) -> None:
1589
1696
  """Give the terminal to a child and exit with its status. Never returns.
1590
1697
 
1591
1698
  Without `restore` this is a plain execv, which is the better deal when
@@ -1604,6 +1711,12 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1604
1711
  the agent starts ten minutes in. It runs on a daemon thread and touches
1605
1712
  neither the terminal nor termios — this path is the one that has to stay
1606
1713
  boring.
1714
+
1715
+ `hint` is the way back in, printed when the child exits badly. A clean exit
1716
+ is either a detach or the agent finishing, and neither wants advice; a bad
1717
+ one is usually ssh losing the connection out from under a session that is
1718
+ 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.
1607
1720
  """
1608
1721
  if not restore:
1609
1722
  os.execv(argv[0], argv)
@@ -1620,19 +1733,25 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1620
1733
  # The child got this same Ctrl-C from the tty and decides
1621
1734
  # for itself what to do with it; outliving it is the point.
1622
1735
  continue
1736
+ if code != 0 and hint:
1737
+ # After the guard, not inside it: a terminal still in the remote
1738
+ # program's modes is no place to print something to be copied.
1739
+ print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
1740
+ f" {hint}\n", file=sys.stderr)
1623
1741
  sys.exit(code)
1624
1742
 
1625
1743
 
1626
- def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None) -> None:
1744
+ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
1745
+ hint: str | None = None) -> None:
1627
1746
  """Hand the terminal to ssh. Never returns."""
1628
1747
  ssh_bin = shutil.which("ssh")
1629
1748
  if not ssh_bin:
1630
1749
  die("'ssh' not found in PATH")
1631
1750
  argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
1632
- hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch)
1751
+ hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
1633
1752
 
1634
1753
 
1635
- def attach_session(hc: dict | None, name: str) -> None:
1754
+ def attach_session(hc: dict | None, name: str, *, hint: str | None = None) -> None:
1636
1755
  """Attach to a tmux session, locally or over ssh. Never returns.
1637
1756
 
1638
1757
  -d detaches other clients so the pane isn't size-clamped to a stale window
@@ -1642,11 +1761,11 @@ def attach_session(hc: dict | None, name: str) -> None:
1642
1761
  tmux_bin = shutil.which("tmux")
1643
1762
  if not tmux_bin:
1644
1763
  die("'tmux' not found in PATH")
1645
- hand_over([tmux_bin, "attach", "-d", "-t", name])
1764
+ hand_over([tmux_bin, "attach", "-d", "-t", name], hint=hint)
1646
1765
  return
1647
1766
  print(f"luv: attaching {name} on {hc['host']}")
1648
1767
  exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]),
1649
- watch=port_watch(hc, session=name))
1768
+ watch=port_watch(hc, session=name), hint=hint)
1650
1769
 
1651
1770
 
1652
1771
  def remote_prompt(args: list[str]) -> str | None:
@@ -1758,14 +1877,18 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
1758
1877
  record_session({**meta, "id": sid, "host": hc["host"], "session": session,
1759
1878
  "workspace": workspace, "created": int(time.time())})
1760
1879
 
1880
+ # Only a tmux session is there to come back to; -nit and --clean run to
1881
+ # completion over ssh and have nothing to continue.
1882
+ hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
1761
1883
  print(f"luv: {hc['host']} — {session or 'no tmux'}")
1762
1884
  if detach:
1763
1885
  r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
1764
1886
  if r.returncode != 0:
1765
1887
  die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
1766
- print(f"luv: started detached — 'luv continue' to attach")
1888
+ print(f"luv: started detached — attach with: {hint or 'luv continue'}")
1767
1889
  return
1768
- exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session))
1890
+ exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
1891
+ hint=hint)
1769
1892
 
1770
1893
 
1771
1894
  def cmd_paths() -> None:
@@ -3145,14 +3268,21 @@ def cmd_continue(args: list[str], identity: str | None = None) -> None:
3145
3268
  live, label = filter_sessions([s for s in sessions if s.get("live")], args)
3146
3269
 
3147
3270
  if not live:
3148
- die(f"no live luv sessions{label}")
3271
+ # This is where a hint printed after a crash lands when the agent took
3272
+ # the tmux session down with it, so send it on rather than stopping at
3273
+ # the bad news: the workspace outlives the session, and -r reopens it.
3274
+ named = bool(args) and not args[0].startswith("-")
3275
+ die(f"no live luv sessions{label}"
3276
+ + (f" — if its workspace is still there: {reopen_hint(args)}"
3277
+ if named else ""))
3149
3278
  live.sort(key=session_sort_key, reverse=True)
3150
3279
 
3151
3280
  # An explicit repo means "the newest one for it".
3152
3281
  target = live[0] if (len(live) == 1 or args) else choose_session(live)
3153
3282
 
3154
3283
  host = target.get("host")
3155
- attach_session(resolve_host(host, identity) if host else None, target["session"])
3284
+ attach_session(resolve_host(host, identity) if host else None, target["session"],
3285
+ hint=continue_hint(target.get("repo"), target.get("workspace")))
3156
3286
 
3157
3287
 
3158
3288
  def start_local_session(workspace: str, luv_args: list[str], meta: dict,
@@ -3170,13 +3300,14 @@ def start_local_session(workspace: str, luv_args: list[str], meta: dict,
3170
3300
  shutil.which("luv") or "luv"] + luv_args
3171
3301
  argv = [tmux_bin, "new-session"] + ([] if attach else ["-d"]) + \
3172
3302
  ["-A", "-s", session, "--"] + inner
3303
+ hint = continue_hint(meta.get("repo"), workspace)
3173
3304
  print(f"luv: local — {session}")
3174
3305
  if attach:
3175
- os.execv(tmux_bin, argv)
3306
+ hand_over(argv, hint=hint)
3176
3307
  r = subprocess.run(argv)
3177
3308
  if r.returncode != 0:
3178
3309
  die(f"could not start {session}")
3179
- print("luv: started detached — 'luv continue' to attach")
3310
+ print(f"luv: started detached — attach with: {hint}")
3180
3311
 
3181
3312
 
3182
3313
  def workspace_origin(hc: dict | None, ws: Path) -> tuple[str, str] | None:
@@ -3404,6 +3535,11 @@ def main() -> None:
3404
3535
  die("--local cannot be combined with -s or -i")
3405
3536
 
3406
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()
3407
3543
  extra_env = collect_luv_env() if env_mode else {}
3408
3544
 
3409
3545
  # _LUV_INNER marks the remote-side luv: it must never dispatch onward.
@@ -3471,7 +3607,8 @@ Remote:
3471
3607
  Once 'luv config' has a remote host, every workspace command runs there inside
3472
3608
  a tmux session that survives disconnects. 'luv ls' shows what is running on
3473
3609
  every host — including sessions started from another machine — and
3474
- 'luv continue' reattaches. Use --local for a one-off local run.
3610
+ 'luv continue' reattaches. A session that ends badly prints the exact
3611
+ 'luv continue <repo> <n>' for itself. Use --local for a one-off local run.
3475
3612
  'luv handover' moves a running session — workspace, uncommitted work, and the
3476
3613
  agent's conversation — to another machine, then resumes it there.
3477
3614
  Requires luv, tmux, gh and git on the remote. See docs/remote-sessions.md.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "luv-cli"
7
- version = "0.5.0"
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"
@@ -387,6 +387,142 @@ class TerminalRestoreTests(unittest.TestCase):
387
387
  self.assertNotEqual(hand_over.call_args.kwargs.get("restore"), False)
388
388
 
389
389
 
390
+ class _OrderedStderr(io.StringIO):
391
+ """A stderr that notes where its first write falls in a sequence."""
392
+
393
+ def __init__(self, order):
394
+ super().__init__()
395
+ self.order = order
396
+
397
+ def write(self, s):
398
+ if s.strip() and "hint" not in self.order:
399
+ self.order.append("hint")
400
+ return super().write(s)
401
+
402
+
403
+ class ContinueHintTests(unittest.TestCase):
404
+ """A session that breaks must say how to get back into it, in one line
405
+ that can be copied off the terminal and run as-is."""
406
+
407
+ FD = TerminalRestoreTests.FD
408
+ SAVED = TerminalRestoreTests.SAVED
409
+ HINT = "luv continue myrepo 42"
410
+
411
+ def _hand_over(self, stderr, **kwargs):
412
+ """Run hand_over over a fake child, with the terminal all stubbed out."""
413
+ with (patch.object(luv, "terminal_fd", return_value=self.FD),
414
+ patch.object(luv.subprocess, "Popen",
415
+ return_value=_FakeProc(kwargs.pop("returncode", 0))),
416
+ patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
417
+ patch.object(luv.termios, "tcsetattr"),
418
+ patch.object(luv.os, "write", side_effect=self.on_write),
419
+ contextlib.redirect_stderr(stderr),
420
+ self.assertRaises(SystemExit) as exit_ctx):
421
+ luv.hand_over(["/bin/ssh", "box"], **kwargs)
422
+ return exit_ctx.exception.code
423
+
424
+ def on_write(self, fd, data):
425
+ return len(data)
426
+
427
+ def test_broken_session_hands_back_a_runnable_command(self):
428
+ err = io.StringIO()
429
+ code = self._hand_over(err, returncode=255, hint=self.HINT)
430
+
431
+ self.assertEqual(code, 255)
432
+ self.assertIn(self.HINT, err.getvalue())
433
+ # Copy-pasteable means the whole command sits on a line of its own, with
434
+ # nothing but indentation around it to select past.
435
+ line = next(l for l in err.getvalue().splitlines() if self.HINT in l)
436
+ self.assertEqual(line.strip(), self.HINT)
437
+
438
+ def test_a_clean_exit_says_nothing(self):
439
+ # Detaching and the agent finishing both land here; neither is broken.
440
+ err = io.StringIO()
441
+ code = self._hand_over(err, returncode=0, hint=self.HINT)
442
+
443
+ self.assertEqual(code, 0)
444
+ self.assertNotIn("luv continue", err.getvalue())
445
+
446
+ def test_the_hint_waits_for_the_terminal_to_be_restored(self):
447
+ # Printed inside the guard it would land in the remote program's modes,
448
+ # which is where unreadable output comes from in the first place.
449
+ order = []
450
+ self.on_write = lambda fd, data: order.append("reset")
451
+ self._hand_over(_OrderedStderr(order), returncode=255, hint=self.HINT)
452
+
453
+ self.assertEqual(order, ["reset", "hint"])
454
+
455
+ def test_hint_names_the_repo_and_number_it_knows(self):
456
+ self.assertEqual(luv.continue_hint("myrepo", "myrepo-box-42"),
457
+ "luv continue myrepo 42")
458
+ self.assertEqual(luv.continue_hint("myrepo", "myrepo-42"),
459
+ "luv continue myrepo 42", "pre-slug folders too")
460
+
461
+ def test_hint_degrades_to_what_is_known(self):
462
+ # A brand-new workspace has no number until the remote picks one, and an
463
+ # adopted session may not even have a repo. Both shorter forms still run.
464
+ self.assertEqual(luv.continue_hint("myrepo", "luv-pending-abc123"),
465
+ "luv continue myrepo")
466
+ self.assertEqual(luv.continue_hint("myrepo", None), "luv continue myrepo")
467
+ self.assertEqual(luv.continue_hint(None, "myrepo-box-42"), "luv continue")
468
+
469
+ def test_remote_session_carries_its_own_hint(self):
470
+ with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
471
+ patch.object(luv, "record_session"),
472
+ patch.object(luv, "port_watch", return_value=None),
473
+ patch.object(luv, "hand_over") as hand_over,
474
+ contextlib.redirect_stdout(io.StringIO())):
475
+ luv.dispatch_remote({"host": "box"}, ["myrepo", "42"],
476
+ workspace="myrepo-box-42",
477
+ meta={"repo": "myrepo", "org": "acme"})
478
+
479
+ self.assertEqual(hand_over.call_args.kwargs.get("hint"),
480
+ "luv continue myrepo 42")
481
+
482
+ def test_a_run_with_no_session_behind_it_gets_no_hint(self):
483
+ # -nit streams to a pipe and exits; there is nothing to continue.
484
+ with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
485
+ patch.object(luv, "port_watch", return_value=None),
486
+ patch.object(luv, "hand_over") as hand_over,
487
+ contextlib.redirect_stdout(io.StringIO())):
488
+ luv.dispatch_remote({"host": "box"}, ["myrepo", "42", "-nit"],
489
+ use_tmux=False, tty=False)
490
+
491
+ self.assertIsNone(hand_over.call_args.kwargs.get("hint"))
492
+
493
+ def test_attaching_carries_the_hint_for_the_session_it_picked(self):
494
+ row = {"host": "box", "session": "luv-myrepo-42", "live": True,
495
+ "repo": "myrepo", "workspace": "myrepo-box-42"}
496
+ with (patch.object(luv, "refresh_sessions", return_value=([row], set())),
497
+ patch.object(luv, "resolve_host", return_value={"host": "box"}),
498
+ patch.object(luv, "attach_session") as attach,
499
+ contextlib.redirect_stdout(io.StringIO())):
500
+ luv.cmd_continue(["myrepo", "42"])
501
+
502
+ self.assertEqual(attach.call_args.kwargs.get("hint"),
503
+ "luv continue myrepo 42")
504
+
505
+ def test_a_session_that_is_already_gone_points_at_the_reopen(self):
506
+ # Where the hint lands when the agent took the tmux session down with
507
+ # it: the workspace outlives the session, so pass the user on.
508
+ with (patch.object(luv, "refresh_sessions", return_value=([], set())),
509
+ contextlib.redirect_stdout(io.StringIO())):
510
+ with self.assertRaises(SystemExit):
511
+ with contextlib.redirect_stderr(io.StringIO()) as err:
512
+ luv.cmd_continue(["myrepo", "42"])
513
+
514
+ self.assertIn("luv myrepo 42 -r", err.getvalue())
515
+
516
+ def test_nothing_live_at_all_suggests_nothing(self):
517
+ with (patch.object(luv, "refresh_sessions", return_value=([], set())),
518
+ contextlib.redirect_stdout(io.StringIO())):
519
+ with self.assertRaises(SystemExit):
520
+ with contextlib.redirect_stderr(io.StringIO()) as err:
521
+ luv.cmd_continue([])
522
+
523
+ self.assertNotIn("-r", err.getvalue())
524
+
525
+
390
526
  class SessionNameTests(unittest.TestCase):
391
527
  def test_illegal_tmux_characters_are_replaced(self):
392
528
  self.assertEqual(luv.tmux_session_name("foo.js-7"), "luv-foo_js-7")
@@ -676,6 +812,102 @@ class ConfigTests(unittest.TestCase):
676
812
  self.assertIn("ConnectTimeout=5", base)
677
813
 
678
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
+
679
911
  class CleanGuardTests(unittest.TestCase):
680
912
  def test_live_session_folder_is_skipped(self):
681
913
  tempdir = tempfile.TemporaryDirectory()
@@ -1,29 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- permissions:
9
- id-token: write
10
-
11
- jobs:
12
- publish:
13
- runs-on: ubuntu-latest
14
- environment: pypi
15
- steps:
16
- - uses: actions/checkout@v4
17
-
18
- - uses: actions/setup-python@v5
19
- with:
20
- python-version: "3.12"
21
-
22
- - name: Install build tools
23
- run: pip install build
24
-
25
- - name: Build package
26
- run: python -m build
27
-
28
- - name: Publish to PyPI
29
- uses: pypa/gh-action-pypi-publish@release/v1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes