luv-cli 0.4.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.4.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
@@ -121,7 +121,9 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
121
121
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
122
122
  | `luv config list` | Show all settings |
123
123
  | `luv --init` | Configure default GitHub org only |
124
- | `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 |
125
127
  | `luv continue [<repo> [number]]` | Attach to a live session |
126
128
  | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
127
129
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
@@ -235,6 +237,20 @@ luv continue # reattach exactly where you left off
235
237
 
236
238
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
237
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
+
238
254
  ### Handing a session to another machine
239
255
 
240
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:
@@ -98,7 +98,9 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
98
98
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
99
99
  | `luv config list` | Show all settings |
100
100
  | `luv --init` | Configure default GitHub org only |
101
- | `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 |
102
104
  | `luv continue [<repo> [number]]` | Attach to a live session |
103
105
  | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
104
106
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
@@ -212,6 +214,20 @@ luv continue # reattach exactly where you left off
212
214
 
213
215
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
214
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
+
215
231
  ### Handing a session to another machine
216
232
 
217
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:
@@ -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,98 @@ 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
+
272
+ ## Reaching servers the agent started
273
+
274
+ An agent working on the remote starts servers there — a dev server, a compose
275
+ stack, a database. luv finds them and tunnels them to this machine, on the same
276
+ port number wherever it is free, so the URL the agent prints in its own output
277
+ is the URL that works here.
278
+
279
+ **The session you are attached to is forwarded automatically.** It is re-checked
280
+ every few seconds, so a server started ten minutes into a session is reachable
281
+ shortly after it comes up, without running anything. When a forward appears the
282
+ session says so on its tmux status line:
283
+
284
+ ```
285
+ luv: localhost:3000 → dashboard, localhost:8080 → server
286
+ ```
287
+
288
+ The ports are also set on the tmux session as `@luv_ports`, if you would rather
289
+ have them permanently in your own `status-right`.
290
+
291
+ **Everything else is opted in by name.** A busy host carries dozens of sessions
292
+ and they should not all get a piece of this machine's port space uninvited:
293
+
294
+ ```bash
295
+ luv ls # PORTS column: what was detected, forwarded or not
296
+ luv ports # detail view; refreshes what is already forwarded
297
+ luv ports myrepo 42 # forward this session's ports
298
+ luv ports myrepo 42 --watch # ...and keep it up to date, printing each change
299
+ luv ports --off myrepo 42 # drop them again
300
+ luv ports --off # drop everything
301
+ ```
302
+
303
+ ```
304
+ HOST WORKSPACE REMOTE LOCAL URL SERVICE STATE
305
+ box myrepo-box-42 3000 3001 http://localhost:3001 dashboard up
306
+ box myrepo-box-42 5173 5173 http://localhost:5173 vite up
307
+ box other-box-17 5432 5432 - postgres detected
308
+ ```
309
+
310
+ Forwards outlive detaching — the session is still running and `localhost:3000`
311
+ should keep working — so `luv ports myrepo 42` is also how a long-running
312
+ session stays reachable after you close the window.
313
+
314
+ `REMOTE` and `LOCAL` differ when the local number was already taken, by another
315
+ session or by something of your own; luv walks up from the remote number and
316
+ remembers what it chose, so the URL stays put across runs.
317
+
318
+ ### What gets found
319
+
320
+ Two sources, because neither sees everything:
321
+
322
+ - **Processes under the session's tmux pane.** A server the agent started
323
+ itself — `npm run dev`, `python -m http.server` — is a descendant of the pane,
324
+ which is what ties the port to a session. Only your own processes are visible
325
+ this way, so other people's services on a shared box never appear.
326
+ - **Docker Compose stacks belonging to the workspace.** A published port is held
327
+ by `docker-proxy` rather than by anything under the pane, so the Compose
328
+ project label answers instead. Both the project luv starts (`luv-<workspace>`)
329
+ and the one an agent gets by running `docker compose up` itself (named after
330
+ the workspace folder) count as the workspace's.
331
+
332
+ A port that is only *exposed* to the Compose network — `9009/tcp` with no host
333
+ mapping — has nothing on the host to point a tunnel at and is not offered. Add a
334
+ `ports:` entry to the compose file if you want it.
335
+
336
+ Forwards bind `127.0.0.1` on this machine, so nothing is republished to your
337
+ network. See [`ports.*`](configuration.md#ports) to change the interval, the
338
+ bind address, or which ports are skipped, and `ports.auto` to turn the automatic
339
+ half off entirely.
340
+
341
+ ### When it doesn't work
342
+
343
+ - **Nothing detected, and the host is otherwise fine.** The host needs a luv new
344
+ enough to know `luv --listening`; an older one reports nothing rather than
345
+ failing. Upgrade luv there.
346
+ - **`warning: <host> refuses port forwarding`.** That host's `sshd_config` has
347
+ `AllowTcpForwarding no`.
348
+ - **A forward that stopped working.** The watcher logs to `~/.luv/ports.log`; it
349
+ cannot print while a full-screen agent UI owns the terminal.
350
+
259
351
  ## Handing a session to another machine
260
352
 
261
353
  `luv handover` relocates a running workspace. The usual direction is laptop →
@@ -330,7 +422,7 @@ machine with `--from` and luv will move the folder and start it fresh.
330
422
  | Step | What happens |
331
423
  |---|---|
332
424
  | `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
333
- | `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 |
334
426
  | `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
335
427
  | `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
336
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 |
@@ -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
  }
@@ -65,6 +68,9 @@ the session (which luv does as soon as the clone lands) breaks nothing.
65
68
  | `pr_checked` | Unix time GitHub was last asked about it |
66
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.