luv-cli 0.5.0__tar.gz → 0.5.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.
@@ -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.5.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
@@ -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 |
@@ -1585,7 +1585,36 @@ def terminal_guard():
1585
1585
  os.write(fd, TERM_RESET.encode())
1586
1586
 
1587
1587
 
1588
- def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1588
+ def continue_hint(repo: str | None, workspace: str | None = None) -> str:
1589
+ """The 'luv continue …' line to hand back when a session breaks.
1590
+
1591
+ As narrow as what we actually know, and no narrower: the number is read off
1592
+ the workspace folder, which a session dispatched before the remote picked
1593
+ one doesn't have yet. Every shorter form is still a command that works —
1594
+ a repo alone takes its newest session, and bare 'luv continue' asks.
1595
+ """
1596
+ parts = ["luv", "continue"]
1597
+ if repo:
1598
+ parts.append(repo)
1599
+ number = workspace_number(repo, workspace or "")
1600
+ if number is not None:
1601
+ parts.append(str(number))
1602
+ return " ".join(parts)
1603
+
1604
+
1605
+ def reopen_hint(args: list[str]) -> str:
1606
+ """The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
1607
+
1608
+ Takes the same [<repo> [number]] grammar 'luv continue' does, so whatever
1609
+ was just typed carries straight over to the command that still works.
1610
+ """
1611
+ repo = args[0].rstrip("/").rsplit("/", 1)[-1]
1612
+ number = args[1] if len(args) > 1 and args[1].isdigit() else None
1613
+ return " ".join(["luv", repo] + ([number] if number else []) + ["-r"])
1614
+
1615
+
1616
+ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1617
+ hint: str | None = None) -> None:
1589
1618
  """Give the terminal to a child and exit with its status. Never returns.
1590
1619
 
1591
1620
  Without `restore` this is a plain execv, which is the better deal when
@@ -1604,6 +1633,12 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1604
1633
  the agent starts ten minutes in. It runs on a daemon thread and touches
1605
1634
  neither the terminal nor termios — this path is the one that has to stay
1606
1635
  boring.
1636
+
1637
+ `hint` is the way back in, printed when the child exits badly. A clean exit
1638
+ is either a detach or the agent finishing, and neither wants advice; a bad
1639
+ one is usually ssh losing the connection out from under a session that is
1640
+ 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.
1607
1642
  """
1608
1643
  if not restore:
1609
1644
  os.execv(argv[0], argv)
@@ -1620,19 +1655,25 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1620
1655
  # The child got this same Ctrl-C from the tty and decides
1621
1656
  # for itself what to do with it; outliving it is the point.
1622
1657
  continue
1658
+ if code != 0 and hint:
1659
+ # After the guard, not inside it: a terminal still in the remote
1660
+ # program's modes is no place to print something to be copied.
1661
+ print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
1662
+ f" {hint}\n", file=sys.stderr)
1623
1663
  sys.exit(code)
1624
1664
 
1625
1665
 
1626
- def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None) -> None:
1666
+ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
1667
+ hint: str | None = None) -> None:
1627
1668
  """Hand the terminal to ssh. Never returns."""
1628
1669
  ssh_bin = shutil.which("ssh")
1629
1670
  if not ssh_bin:
1630
1671
  die("'ssh' not found in PATH")
1631
1672
  argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
1632
- hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch)
1673
+ hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
1633
1674
 
1634
1675
 
1635
- def attach_session(hc: dict | None, name: str) -> None:
1676
+ def attach_session(hc: dict | None, name: str, *, hint: str | None = None) -> None:
1636
1677
  """Attach to a tmux session, locally or over ssh. Never returns.
1637
1678
 
1638
1679
  -d detaches other clients so the pane isn't size-clamped to a stale window
@@ -1642,11 +1683,11 @@ def attach_session(hc: dict | None, name: str) -> None:
1642
1683
  tmux_bin = shutil.which("tmux")
1643
1684
  if not tmux_bin:
1644
1685
  die("'tmux' not found in PATH")
1645
- hand_over([tmux_bin, "attach", "-d", "-t", name])
1686
+ hand_over([tmux_bin, "attach", "-d", "-t", name], hint=hint)
1646
1687
  return
1647
1688
  print(f"luv: attaching {name} on {hc['host']}")
1648
1689
  exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]),
1649
- watch=port_watch(hc, session=name))
1690
+ watch=port_watch(hc, session=name), hint=hint)
1650
1691
 
1651
1692
 
1652
1693
  def remote_prompt(args: list[str]) -> str | None:
@@ -1758,14 +1799,18 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
1758
1799
  record_session({**meta, "id": sid, "host": hc["host"], "session": session,
1759
1800
  "workspace": workspace, "created": int(time.time())})
1760
1801
 
1802
+ # Only a tmux session is there to come back to; -nit and --clean run to
1803
+ # completion over ssh and have nothing to continue.
1804
+ hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
1761
1805
  print(f"luv: {hc['host']} — {session or 'no tmux'}")
1762
1806
  if detach:
1763
1807
  r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
1764
1808
  if r.returncode != 0:
1765
1809
  die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
1766
- print(f"luv: started detached — 'luv continue' to attach")
1810
+ print(f"luv: started detached — attach with: {hint or 'luv continue'}")
1767
1811
  return
1768
- exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session))
1812
+ exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
1813
+ hint=hint)
1769
1814
 
1770
1815
 
1771
1816
  def cmd_paths() -> None:
@@ -3145,14 +3190,21 @@ def cmd_continue(args: list[str], identity: str | None = None) -> None:
3145
3190
  live, label = filter_sessions([s for s in sessions if s.get("live")], args)
3146
3191
 
3147
3192
  if not live:
3148
- die(f"no live luv sessions{label}")
3193
+ # This is where a hint printed after a crash lands when the agent took
3194
+ # the tmux session down with it, so send it on rather than stopping at
3195
+ # the bad news: the workspace outlives the session, and -r reopens it.
3196
+ named = bool(args) and not args[0].startswith("-")
3197
+ die(f"no live luv sessions{label}"
3198
+ + (f" — if its workspace is still there: {reopen_hint(args)}"
3199
+ if named else ""))
3149
3200
  live.sort(key=session_sort_key, reverse=True)
3150
3201
 
3151
3202
  # An explicit repo means "the newest one for it".
3152
3203
  target = live[0] if (len(live) == 1 or args) else choose_session(live)
3153
3204
 
3154
3205
  host = target.get("host")
3155
- attach_session(resolve_host(host, identity) if host else None, target["session"])
3206
+ attach_session(resolve_host(host, identity) if host else None, target["session"],
3207
+ hint=continue_hint(target.get("repo"), target.get("workspace")))
3156
3208
 
3157
3209
 
3158
3210
  def start_local_session(workspace: str, luv_args: list[str], meta: dict,
@@ -3170,13 +3222,14 @@ def start_local_session(workspace: str, luv_args: list[str], meta: dict,
3170
3222
  shutil.which("luv") or "luv"] + luv_args
3171
3223
  argv = [tmux_bin, "new-session"] + ([] if attach else ["-d"]) + \
3172
3224
  ["-A", "-s", session, "--"] + inner
3225
+ hint = continue_hint(meta.get("repo"), workspace)
3173
3226
  print(f"luv: local — {session}")
3174
3227
  if attach:
3175
- os.execv(tmux_bin, argv)
3228
+ hand_over(argv, hint=hint)
3176
3229
  r = subprocess.run(argv)
3177
3230
  if r.returncode != 0:
3178
3231
  die(f"could not start {session}")
3179
- print("luv: started detached — 'luv continue' to attach")
3232
+ print(f"luv: started detached — attach with: {hint}")
3180
3233
 
3181
3234
 
3182
3235
  def workspace_origin(hc: dict | None, ws: Path) -> tuple[str, str] | None:
@@ -3471,7 +3524,8 @@ Remote:
3471
3524
  Once 'luv config' has a remote host, every workspace command runs there inside
3472
3525
  a tmux session that survives disconnects. 'luv ls' shows what is running on
3473
3526
  every host — including sessions started from another machine — and
3474
- 'luv continue' reattaches. Use --local for a one-off local run.
3527
+ 'luv continue' reattaches. A session that ends badly prints the exact
3528
+ 'luv continue <repo> <n>' for itself. Use --local for a one-off local run.
3475
3529
  'luv handover' moves a running session — workspace, uncommitted work, and the
3476
3530
  agent's conversation — to another machine, then resumes it there.
3477
3531
  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.5.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"
@@ -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")
@@ -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
File without changes
File without changes