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.
- luv_cli-0.5.1/.failproofai/policies/version-policies.mjs +107 -0
- luv_cli-0.5.1/.github/workflows/publish.yml +117 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/PKG-INFO +1 -1
- {luv_cli-0.5.0 → luv_cli-0.5.1}/docs/remote-sessions.md +14 -1
- {luv_cli-0.5.0 → luv_cli-0.5.1}/luv/__init__.py +67 -13
- {luv_cli-0.5.0 → luv_cli-0.5.1}/pyproject.toml +1 -1
- {luv_cli-0.5.0 → luv_cli-0.5.1}/tests/test_agents.py +136 -0
- luv_cli-0.5.0/.github/workflows/publish.yml +0 -29
- {luv_cli-0.5.0 → luv_cli-0.5.1}/.claude/settings.json +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/.gitignore +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/LICENSE +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/README.md +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/claude-output.txt +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/docs/configuration.md +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/docs/sessions.md +0 -0
- {luv_cli-0.5.0 → luv_cli-0.5.1}/tests/__init__.py +0 -0
|
@@ -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.
|
|
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
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|