luv-cli 0.3.0__tar.gz → 0.5.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.3.0
3
+ Version: 0.5.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
@@ -63,7 +63,7 @@ luv other-org/my-repo "fix the bug"
63
63
  # Reopen workspace #42
64
64
  luv my-repo 42
65
65
 
66
- # Open any GitHub PR by URL
66
+ # Clone any GitHub PR by URL into a fresh workspace
67
67
  luv -l https://github.com/org/repo/pull/123
68
68
 
69
69
  # Open a shell instead of Claude
@@ -97,6 +97,22 @@ The machine name is what keeps two computers apart. Each one works out the next
97
97
 
98
98
  With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
99
99
 
100
+ ### Opening an existing PR
101
+
102
+ `luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
103
+ workspace of its own, checked out on the PR's head branch. It never reuses a
104
+ folder that happens to be sitting there — a URL you paste is a request to look
105
+ at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
106
+ it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
107
+ which are independent workspaces you can run side by side. When the PR comes
108
+ from a fork, the base repo is added as `upstream` and fetched too, so the branch
109
+ you need to diff or rebase against is there.
110
+
111
+ `-r` is the exception: resuming means picking up a conversation, and a
112
+ conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
113
+ newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
114
+ also keeps reusing its workspace — it names a workspace, where a URL names a PR.
115
+
100
116
  ## Commands
101
117
 
102
118
  | Command | Description |
@@ -105,7 +121,9 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
105
121
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
106
122
  | `luv config list` | Show all settings |
107
123
  | `luv --init` | Configure default GitHub org only |
108
- | `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
124
+ | `luv ls [--host H] [--prune] [--no-pr] [--no-ports]` | List every live session on every host, with each one's PR link and detected ports |
125
+ | `luv ports [<repo> [n]] [--watch [N]]` | Show ports the sessions are listening on; naming one forwards it to localhost |
126
+ | `luv ports --off [<repo> [n]]` | Drop forwards again |
109
127
  | `luv continue [<repo> [number]]` | Attach to a live session |
110
128
  | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
111
129
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
@@ -115,8 +133,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
115
133
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
116
134
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
117
135
  | `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
118
- | `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
119
- | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
136
+ | `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
137
+ | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
120
138
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
121
139
  | `luv --clean -f` | Force delete all workspaces |
122
140
  | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
@@ -219,6 +237,20 @@ luv continue # reattach exactly where you left off
219
237
 
220
238
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
221
239
 
240
+ ### Reaching servers the agent started
241
+
242
+ Agents working on a remote start servers there. luv finds them and forwards them to this machine on the same port number wherever it's free, so the URL in the agent's output is the URL that works for you.
243
+
244
+ ```bash
245
+ luv ls # PORTS column: what was detected, on every session
246
+ luv ports myrepo 42 # forward that session's ports to localhost
247
+ luv ports --off myrepo 42 # drop them again
248
+ ```
249
+
250
+ The session you're **attached to** is forwarded automatically as servers come and go, and announces each one on the tmux status line (`luv: localhost:3000 → dashboard`) — nothing to run. Everything else is opted in by name, because a busy host carries dozens of sessions and they shouldn't all claim local ports uninvited.
251
+
252
+ Both processes under the session's tmux pane and Docker Compose stacks belonging to the workspace are found; forwards bind `127.0.0.1`, and they outlive detaching so a long-running session stays reachable.
253
+
222
254
  ### Handing a session to another machine
223
255
 
224
256
  Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
@@ -270,9 +302,10 @@ registry as soon as its tmux session exits, but the clone it was working in
270
302
  stays on the remote disk. Those orphans are invisible to `luv ls` and are
271
303
  usually what is actually filling up the box.
272
304
 
273
- Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}` and
274
- pre-slug `{repo}-{N}` alike. Anything else in the workspace root is left alone,
275
- and a target that doesn't resolve to one is an error rather than an `rm -rf`.
305
+ Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
306
+ `luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
307
+ Anything else in the workspace root is left alone, and a target that doesn't
308
+ resolve to one is an error rather than an `rm -rf`.
276
309
 
277
310
  ## Configuration
278
311
 
@@ -40,7 +40,7 @@ luv other-org/my-repo "fix the bug"
40
40
  # Reopen workspace #42
41
41
  luv my-repo 42
42
42
 
43
- # Open any GitHub PR by URL
43
+ # Clone any GitHub PR by URL into a fresh workspace
44
44
  luv -l https://github.com/org/repo/pull/123
45
45
 
46
46
  # Open a shell instead of Claude
@@ -74,6 +74,22 @@ The machine name is what keeps two computers apart. Each one works out the next
74
74
 
75
75
  With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
76
76
 
77
+ ### Opening an existing PR
78
+
79
+ `luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
80
+ workspace of its own, checked out on the PR's head branch. It never reuses a
81
+ folder that happens to be sitting there — a URL you paste is a request to look
82
+ at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
83
+ it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
84
+ which are independent workspaces you can run side by side. When the PR comes
85
+ from a fork, the base repo is added as `upstream` and fetched too, so the branch
86
+ you need to diff or rebase against is there.
87
+
88
+ `-r` is the exception: resuming means picking up a conversation, and a
89
+ conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
90
+ newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
91
+ also keeps reusing its workspace — it names a workspace, where a URL names a PR.
92
+
77
93
  ## Commands
78
94
 
79
95
  | Command | Description |
@@ -82,7 +98,9 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
82
98
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
83
99
  | `luv config list` | Show all settings |
84
100
  | `luv --init` | Configure default GitHub org only |
85
- | `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
101
+ | `luv ls [--host H] [--prune] [--no-pr] [--no-ports]` | List every live session on every host, with each one's PR link and detected ports |
102
+ | `luv ports [<repo> [n]] [--watch [N]]` | Show ports the sessions are listening on; naming one forwards it to localhost |
103
+ | `luv ports --off [<repo> [n]]` | Drop forwards again |
86
104
  | `luv continue [<repo> [number]]` | Attach to a live session |
87
105
  | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
88
106
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
@@ -92,8 +110,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
92
110
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
93
111
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
94
112
  | `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
95
- | `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
96
- | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
113
+ | `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
114
+ | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
97
115
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
98
116
  | `luv --clean -f` | Force delete all workspaces |
99
117
  | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
@@ -196,6 +214,20 @@ luv continue # reattach exactly where you left off
196
214
 
197
215
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
198
216
 
217
+ ### Reaching servers the agent started
218
+
219
+ Agents working on a remote start servers there. luv finds them and forwards them to this machine on the same port number wherever it's free, so the URL in the agent's output is the URL that works for you.
220
+
221
+ ```bash
222
+ luv ls # PORTS column: what was detected, on every session
223
+ luv ports myrepo 42 # forward that session's ports to localhost
224
+ luv ports --off myrepo 42 # drop them again
225
+ ```
226
+
227
+ The session you're **attached to** is forwarded automatically as servers come and go, and announces each one on the tmux status line (`luv: localhost:3000 → dashboard`) — nothing to run. Everything else is opted in by name, because a busy host carries dozens of sessions and they shouldn't all claim local ports uninvited.
228
+
229
+ Both processes under the session's tmux pane and Docker Compose stacks belonging to the workspace are found; forwards bind `127.0.0.1`, and they outlive detaching so a long-running session stays reachable.
230
+
199
231
  ### Handing a session to another machine
200
232
 
201
233
  Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
@@ -247,9 +279,10 @@ registry as soon as its tmux session exits, but the clone it was working in
247
279
  stays on the remote disk. Those orphans are invisible to `luv ls` and are
248
280
  usually what is actually filling up the box.
249
281
 
250
- Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}` and
251
- pre-slug `{repo}-{N}` alike. Anything else in the workspace root is left alone,
252
- and a target that doesn't resolve to one is an error rather than an `rm -rf`.
282
+ Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
283
+ `luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
284
+ Anything else in the workspace root is left alone, and a target that doesn't
285
+ resolve to one is an error rather than an `rm -rf`.
253
286
 
254
287
  ## Configuration
255
288
 
@@ -109,6 +109,30 @@ or `ssh_opts` may be set here and takes precedence over the `remote.*` value of
109
109
  the same name. This is what makes `-s otherbox` usable: without it, one global
110
110
  key would be wrong for every host but the default.
111
111
 
112
+ ### `ports`
113
+
114
+ *object.* Port forwarding. Servers an agent starts on a remote host are
115
+ detected and tunnelled to this machine — see
116
+ [remote-sessions.md](remote-sessions.md#reaching-servers-the-agent-started).
117
+ Every key is optional:
118
+
119
+ | Key | Default | Meaning |
120
+ | --- | --- | --- |
121
+ | `auto` | `true` | Forward the session you are attached to, as servers come and go. `false` leaves everything to `luv ports`. |
122
+ | `interval` | `10` | Seconds between re-checks while attached. Minimum 2. |
123
+ | `bind` | `"127.0.0.1"` | Local address the forwards listen on. `"0.0.0.0"` would publish the agent's dev server to your whole network. |
124
+ | `min` | `1024` | Ports below this are ignored; a dev server is not on 80. |
125
+ | `ignore` | `[]` | Specific ports never to forward. |
126
+ | `max_per_session` | `12` | Cap on forwards per session, so one stack cannot eat the local port space. |
127
+
128
+ ```bash
129
+ luv config set ports.interval 5
130
+ luv config set ports.ignore '[5432, 6379]'
131
+ luv config set ports.auto false
132
+ ```
133
+
134
+ Unlike `remote.*`, these are global — there is no `remote.hosts.<name>.ports`.
135
+
112
136
  ## Resolution order
113
137
 
114
138
  **Host:** `-s HOST` → `remote.host`. If neither, luv runs locally.
@@ -256,6 +256,85 @@ 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
+ ## Reaching servers the agent started
260
+
261
+ An agent working on the remote starts servers there — a dev server, a compose
262
+ stack, a database. luv finds them and tunnels them to this machine, on the same
263
+ port number wherever it is free, so the URL the agent prints in its own output
264
+ is the URL that works here.
265
+
266
+ **The session you are attached to is forwarded automatically.** It is re-checked
267
+ every few seconds, so a server started ten minutes into a session is reachable
268
+ shortly after it comes up, without running anything. When a forward appears the
269
+ session says so on its tmux status line:
270
+
271
+ ```
272
+ luv: localhost:3000 → dashboard, localhost:8080 → server
273
+ ```
274
+
275
+ The ports are also set on the tmux session as `@luv_ports`, if you would rather
276
+ have them permanently in your own `status-right`.
277
+
278
+ **Everything else is opted in by name.** A busy host carries dozens of sessions
279
+ and they should not all get a piece of this machine's port space uninvited:
280
+
281
+ ```bash
282
+ luv ls # PORTS column: what was detected, forwarded or not
283
+ luv ports # detail view; refreshes what is already forwarded
284
+ luv ports myrepo 42 # forward this session's ports
285
+ luv ports myrepo 42 --watch # ...and keep it up to date, printing each change
286
+ luv ports --off myrepo 42 # drop them again
287
+ luv ports --off # drop everything
288
+ ```
289
+
290
+ ```
291
+ HOST WORKSPACE REMOTE LOCAL URL SERVICE STATE
292
+ box myrepo-box-42 3000 3001 http://localhost:3001 dashboard up
293
+ box myrepo-box-42 5173 5173 http://localhost:5173 vite up
294
+ box other-box-17 5432 5432 - postgres detected
295
+ ```
296
+
297
+ Forwards outlive detaching — the session is still running and `localhost:3000`
298
+ should keep working — so `luv ports myrepo 42` is also how a long-running
299
+ session stays reachable after you close the window.
300
+
301
+ `REMOTE` and `LOCAL` differ when the local number was already taken, by another
302
+ session or by something of your own; luv walks up from the remote number and
303
+ remembers what it chose, so the URL stays put across runs.
304
+
305
+ ### What gets found
306
+
307
+ Two sources, because neither sees everything:
308
+
309
+ - **Processes under the session's tmux pane.** A server the agent started
310
+ itself — `npm run dev`, `python -m http.server` — is a descendant of the pane,
311
+ which is what ties the port to a session. Only your own processes are visible
312
+ this way, so other people's services on a shared box never appear.
313
+ - **Docker Compose stacks belonging to the workspace.** A published port is held
314
+ by `docker-proxy` rather than by anything under the pane, so the Compose
315
+ project label answers instead. Both the project luv starts (`luv-<workspace>`)
316
+ and the one an agent gets by running `docker compose up` itself (named after
317
+ the workspace folder) count as the workspace's.
318
+
319
+ A port that is only *exposed* to the Compose network — `9009/tcp` with no host
320
+ mapping — has nothing on the host to point a tunnel at and is not offered. Add a
321
+ `ports:` entry to the compose file if you want it.
322
+
323
+ Forwards bind `127.0.0.1` on this machine, so nothing is republished to your
324
+ network. See [`ports.*`](configuration.md#ports) to change the interval, the
325
+ bind address, or which ports are skipped, and `ports.auto` to turn the automatic
326
+ half off entirely.
327
+
328
+ ### When it doesn't work
329
+
330
+ - **Nothing detected, and the host is otherwise fine.** The host needs a luv new
331
+ enough to know `luv --listening`; an older one reports nothing rather than
332
+ failing. Upgrade luv there.
333
+ - **`warning: <host> refuses port forwarding`.** That host's `sshd_config` has
334
+ `AllowTcpForwarding no`.
335
+ - **A forward that stopped working.** The watcher logs to `~/.luv/ports.log`; it
336
+ cannot print while a full-screen agent UI owns the terminal.
337
+
259
338
  ## Handing a session to another machine
260
339
 
261
340
  `luv handover` relocates a running workspace. The usual direction is laptop →
@@ -345,19 +424,27 @@ a laptop and a box would routinely pick the same one and push the same branch.
345
424
  Folders and branches created before slugs existed keep working — luv looks for
346
425
  its own name first, then any other machine's, then the plain `{repo}-{number}`.
347
426
 
427
+ `luv -l <PR URL>` clones into a folder of its own every time, so a second run on
428
+ the same PR gets `{repo}-{machine}-{number}_2`, a third `_3`. The suffix is part
429
+ of the number's segment, so those folders are still workspace `{number}`
430
+ everywhere it matters — `luv ls`, `luv rm`, `--clean`, handover. Where a number
431
+ has several copies, the newest wins: that is the one you just made.
432
+
348
433
  Sessions are named after the workspace folder: `~/prs/myrepo-box-42` becomes
349
434
  `luv-myrepo-box-42`. tmux forbids `.` and `:` in session names, so a repo like
350
435
  `foo.js` becomes `luv-foo_js-box-42`.
351
436
 
352
- When the workspace is already known — reopening by number, `-pr`, or `-l` — the
353
- laptop pins the session name down before dispatching, so `tmux new-session -A`
354
- doubles as "attach if it's already running". It can't compute that name, though,
355
- since the slug belongs to the machine holding the folder: it takes it from the
356
- session registry, or asks the host directly, and falls back to the
437
+ When the workspace is already known — reopening by number, `-pr`, or `-l -r` —
438
+ the laptop pins the session name down before dispatching, so `tmux new-session
439
+ -A` doubles as "attach if it's already running". It can't compute that name,
440
+ though, since the slug belongs to the machine holding the folder: it takes it
441
+ from the session registry, or asks the host directly, and falls back to the
357
442
  `luv-pending-<id>` rename-on-arrival path if the host doesn't answer.
358
443
 
359
- When it isn't known — a brand new workspace, or bare `-n`/`-r` — the laptop uses
360
- a placeholder `luv-pending-<id>` and the remote renames it once the clone lands.
444
+ When it isn't known — a brand new workspace, plain `luv -l` (whose folder the
445
+ remote only picks once it sees what is already there), or bare `-n`/`-r` — the
446
+ laptop uses a placeholder `luv-pending-<id>` and the remote renames it once the
447
+ clone lands.
361
448
  If the target name is already taken by another live session, luv keeps the
362
449
  session under `luv-myrepo-box-42-2` and warns rather than failing.
363
450
 
@@ -42,7 +42,10 @@ the session (which luv does as soon as the clone lands) breaks nothing.
42
42
  "pr_number": 42,
43
43
  "pr_url": "https://github.com/exosphere/myrepo/pull/42",
44
44
  "pr_state": "OPEN",
45
- "pr_checked": 1753408899
45
+ "pr_checked": 1753408899,
46
+ "ports": [3000, 5173],
47
+ "forwards": [{"remote": 3000, "local": 3001, "label": "dashboard"}],
48
+ "ports_checked": 1753408899
46
49
  }
47
50
  ]
48
51
  }
@@ -63,8 +66,11 @@ the session (which luv does as soon as the clone lands) breaks nothing.
63
66
  | `pr_number`, `pr_url` | The session's pull request; `null` until one exists |
64
67
  | `pr_state` | `OPEN`, `CLOSED` or `MERGED` — what `luv rm --merged` selects on |
65
68
  | `pr_checked` | Unix time GitHub was last asked about it |
66
- | `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise |
69
+ | `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise. Set even when the folder isn't yet known, which is always the case for `-l` |
67
70
  | `adopted` | Present when this machine found the session rather than starting it |
71
+ | `ports` | Ports the session was last seen listening on, on its own host |
72
+ | `forwards` | Live tunnels to this machine: `remote` port, the `local` one it landed on, and the service `label`. Persisted so a URL stays put across runs, and so a forward survives detaching |
73
+ | `ports_checked` | Unix time the forwards were last brought in line |
68
74
 
69
75
  `attached`, `activity`, and `live` are recomputed on every reconcile and are
70
76
  deliberately **not** written back to the file.
@@ -230,10 +236,11 @@ case.
230
236
 
231
237
  Two things bound the damage:
232
238
 
233
- - Only names ending in `-{N}` are eligible — `{repo}-{machine}-{N}` and pre-slug
234
- `{repo}-{N}` alike — checked again immediately before the `rm -rf` rather than
235
- only at selection time. A stray directory in the workspace root is never a
236
- candidate, and a target that resolves to one is an error, not a deletion.
239
+ - Only names ending in `-{N}` are eligible — `{repo}-{machine}-{N}`, a `luv -l`
240
+ copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike — checked again
241
+ immediately before the `rm -rf` rather than only at selection time. A stray
242
+ directory in the workspace root is never a candidate, and a target that
243
+ resolves to one is an error, not a deletion.
237
244
  - A named target is its own confirmation, but `--merged` and `--dead` print what
238
245
  they matched and ask, because a selector can sweep up folders on a machine you
239
246
  are not looking at. `-f` skips the prompt.