luv-cli 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- luv_cli-0.6.0/.failproofai/policies/version-policies.mjs +107 -0
- luv_cli-0.6.0/.github/workflows/publish.yml +117 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/PKG-INFO +27 -1
- {luv_cli-0.5.0 → luv_cli-0.6.0}/README.md +26 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/docs/configuration.md +29 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/docs/remote-sessions.md +27 -1
- {luv_cli-0.5.0 → luv_cli-0.6.0}/luv/__init__.py +153 -16
- {luv_cli-0.5.0 → luv_cli-0.6.0}/pyproject.toml +1 -1
- {luv_cli-0.5.0 → luv_cli-0.6.0}/tests/test_agents.py +232 -0
- luv_cli-0.5.0/.github/workflows/publish.yml +0 -29
- {luv_cli-0.5.0 → luv_cli-0.6.0}/.claude/settings.json +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/.gitignore +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/LICENSE +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/claude-output.txt +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/docs/sessions.md +0 -0
- {luv_cli-0.5.0 → luv_cli-0.6.0}/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.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments
|
|
5
5
|
Project-URL: Homepage, https://github.com/exospherehost/luv
|
|
6
6
|
Project-URL: Repository, https://github.com/exospherehost/luv
|
|
@@ -169,6 +169,32 @@ Handover flags:
|
|
|
169
169
|
| `--purge` | Delete the source folder once the copy is verified |
|
|
170
170
|
| `-y` | Skip the "an agent may still be running" confirmation |
|
|
171
171
|
|
|
172
|
+
## The session's environment
|
|
173
|
+
|
|
174
|
+
A session luv starts for you — a remote dispatch, a handover, a detached start
|
|
175
|
+
— is exec'd by tmux or ssh with no shell in between, so it never sources
|
|
176
|
+
`~/.bashrc` or `~/.zshrc` and would otherwise run with whatever environment the
|
|
177
|
+
tmux server happened to start with. luv asks your login shell (`$SHELL -lic`)
|
|
178
|
+
for its environment and fills in the gaps before cloning or launching anything,
|
|
179
|
+
so the agent gets the API keys and the `PATH` you'd have in a terminal there.
|
|
180
|
+
|
|
181
|
+
Precedence, highest first:
|
|
182
|
+
|
|
183
|
+
1. Anything already set — `FOO=bar luv …`, or `-e` — is a decision about *this*
|
|
184
|
+
session and is never overwritten.
|
|
185
|
+
2. Your rc's exports fill in the rest. `PATH` is merged instead of replaced:
|
|
186
|
+
entries the rc adds (nvm, pyenv, `~/.local/bin`) are appended after the ones
|
|
187
|
+
already there.
|
|
188
|
+
|
|
189
|
+
The probe runs on the machine the session runs on, so a remote session picks up
|
|
190
|
+
the *remote* user's rc. It is skipped when you run `luv` yourself — that
|
|
191
|
+
process already inherited your shell — and can be turned off entirely with
|
|
192
|
+
`luv config set shell_env false`. An rc that hangs is given 15 seconds and then
|
|
193
|
+
ignored.
|
|
194
|
+
|
|
195
|
+
Docker sessions are the exception: the container has its own environment, and
|
|
196
|
+
only `-e` crosses into it (see below).
|
|
197
|
+
|
|
172
198
|
## Docker dev environments
|
|
173
199
|
|
|
174
200
|
If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
|
|
@@ -146,6 +146,32 @@ Handover flags:
|
|
|
146
146
|
| `--purge` | Delete the source folder once the copy is verified |
|
|
147
147
|
| `-y` | Skip the "an agent may still be running" confirmation |
|
|
148
148
|
|
|
149
|
+
## The session's environment
|
|
150
|
+
|
|
151
|
+
A session luv starts for you — a remote dispatch, a handover, a detached start
|
|
152
|
+
— is exec'd by tmux or ssh with no shell in between, so it never sources
|
|
153
|
+
`~/.bashrc` or `~/.zshrc` and would otherwise run with whatever environment the
|
|
154
|
+
tmux server happened to start with. luv asks your login shell (`$SHELL -lic`)
|
|
155
|
+
for its environment and fills in the gaps before cloning or launching anything,
|
|
156
|
+
so the agent gets the API keys and the `PATH` you'd have in a terminal there.
|
|
157
|
+
|
|
158
|
+
Precedence, highest first:
|
|
159
|
+
|
|
160
|
+
1. Anything already set — `FOO=bar luv …`, or `-e` — is a decision about *this*
|
|
161
|
+
session and is never overwritten.
|
|
162
|
+
2. Your rc's exports fill in the rest. `PATH` is merged instead of replaced:
|
|
163
|
+
entries the rc adds (nvm, pyenv, `~/.local/bin`) are appended after the ones
|
|
164
|
+
already there.
|
|
165
|
+
|
|
166
|
+
The probe runs on the machine the session runs on, so a remote session picks up
|
|
167
|
+
the *remote* user's rc. It is skipped when you run `luv` yourself — that
|
|
168
|
+
process already inherited your shell — and can be turned off entirely with
|
|
169
|
+
`luv config set shell_env false`. An rc that hangs is given 15 seconds and then
|
|
170
|
+
ignored.
|
|
171
|
+
|
|
172
|
+
Docker sessions are the exception: the container has its own environment, and
|
|
173
|
+
only `-e` crosses into it (see below).
|
|
174
|
+
|
|
149
175
|
## Docker dev environments
|
|
150
176
|
|
|
151
177
|
If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
|
|
@@ -70,6 +70,35 @@ pushed. Workspaces created before slugs existed keep working.
|
|
|
70
70
|
*string, default `~/prs`.* Where workspace folders are created on **this**
|
|
71
71
|
machine. `~` is expanded.
|
|
72
72
|
|
|
73
|
+
### `shell_env`
|
|
74
|
+
|
|
75
|
+
*boolean, default `true`.* Whether a session luv starts should inherit your
|
|
76
|
+
shell's environment.
|
|
77
|
+
|
|
78
|
+
tmux and ssh exec their command directly, so a remote dispatch, a handover, or
|
|
79
|
+
a detached start never sources `~/.bashrc` or `~/.zshrc` — it runs with the
|
|
80
|
+
environment the tmux server was started with, frozen at whenever that server
|
|
81
|
+
first came up. Anything you export from your rc (the API key an agent
|
|
82
|
+
authenticates with, the `PATH` entry that makes `codex` resolvable) is simply
|
|
83
|
+
absent, and only for the sessions luv started for you.
|
|
84
|
+
|
|
85
|
+
With this on, luv runs `$SHELL -lic` on the machine the session runs on and
|
|
86
|
+
fills in what's missing before it clones or launches anything. Values already
|
|
87
|
+
set win, so `FOO=bar luv …` and `-e` still decide; `PATH` is merged rather than
|
|
88
|
+
replaced, with the rc's entries appended. An rc that never returns is abandoned
|
|
89
|
+
after 15 seconds.
|
|
90
|
+
|
|
91
|
+
Set it to `false` if your rc is slow, or if you want sessions to see only what
|
|
92
|
+
you pass them explicitly:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
luv config set shell_env false
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This is read on the machine the session runs on, so a remote host can opt out
|
|
99
|
+
independently of your laptop. It does not apply to Docker sessions — the
|
|
100
|
+
container has its own environment, and only `-e` crosses into it.
|
|
101
|
+
|
|
73
102
|
### `remote.host`
|
|
74
103
|
|
|
75
104
|
*string.* SSH destination — an `~/.ssh/config` alias (`box`), a `user@host`, or
|
|
@@ -256,6 +256,19 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
|
|
|
256
256
|
Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
|
|
257
257
|
running.
|
|
258
258
|
|
|
259
|
+
When a session ends on its own — the connection drops, ssh is killed, the pane
|
|
260
|
+
dies — luv prints the way back in, already filled in for that session:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
luv: session ended unexpectedly — continue it with:
|
|
264
|
+
|
|
265
|
+
luv continue myrepo 42
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Copy that line and run it. If the agent took the tmux session down with it,
|
|
269
|
+
there is nothing left to attach to; `luv continue` says so and hands you
|
|
270
|
+
`luv myrepo 42 -r`, which reopens the workspace and resumes the conversation.
|
|
271
|
+
|
|
259
272
|
## Reaching servers the agent started
|
|
260
273
|
|
|
261
274
|
An agent working on the remote starts servers there — a dev server, a compose
|
|
@@ -409,7 +422,7 @@ machine with `--from` and luv will move the folder and start it fresh.
|
|
|
409
422
|
| Step | What happens |
|
|
410
423
|
|---|---|
|
|
411
424
|
| `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
|
|
412
|
-
| `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up |
|
|
425
|
+
| `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up. A connection that broke leaves the exact `luv continue <repo> <n>` for it in your terminal |
|
|
413
426
|
| `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
|
|
414
427
|
| `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
|
|
415
428
|
| Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, `luv ls` prunes the entry on its next run |
|
|
@@ -508,6 +521,19 @@ The key isn't installed or isn't being offered. Test with `ssh -v box`. Check
|
|
|
508
521
|
`gh` is authenticated on your laptop but not on the remote. luv numbers
|
|
509
522
|
workspaces with `gh api` *on the remote*, so it needs its own login there.
|
|
510
523
|
|
|
524
|
+
**The agent can't find an API key / a tool it needs is missing**
|
|
525
|
+
Sessions inherit your rc: before cloning or launching, luv asks the remote's
|
|
526
|
+
login shell (`$SHELL -lic`) for its environment and fills in whatever tmux
|
|
527
|
+
didn't pass on, `PATH` included. So this means the var isn't exported by the
|
|
528
|
+
*remote* user's `~/.bashrc`/`~/.zshrc` — check with
|
|
529
|
+
`ssh box 'zsh -lic "printenv | cut -d= -f1"'` — or that the rc takes longer than
|
|
530
|
+
15 seconds, or that `shell_env` is `false` there. `-e` with a `LUV_`-prefixed
|
|
531
|
+
variable forwards one from your laptop regardless. See
|
|
532
|
+
[configuration.md](configuration.md#shell_env).
|
|
533
|
+
|
|
534
|
+
Note that this happens *inside* the remote luv, so it can't help ssh find `luv`
|
|
535
|
+
itself — that's the `PATH` problem above.
|
|
536
|
+
|
|
511
537
|
**My prompt didn't get sent**
|
|
512
538
|
Reopening a workspace that already has a live session attaches to it, and tmux
|
|
513
539
|
discards the command — so the prompt is dropped. luv prints a note when this can
|
|
@@ -57,13 +57,13 @@ def die(msg: str) -> None:
|
|
|
57
57
|
sys.exit(1)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def run(cmd: list[str], *, cwd: str | None = None,
|
|
61
|
-
|
|
60
|
+
def run(cmd: list[str], *, cwd: str | None = None, timeout: float | None = None,
|
|
61
|
+
stdin: int | None = None) -> subprocess.CompletedProcess:
|
|
62
62
|
"""Capture a subprocess. A timeout surfaces as a failure, not an exception,
|
|
63
63
|
so callers keep their plain returncode checks."""
|
|
64
64
|
try:
|
|
65
65
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd,
|
|
66
|
-
timeout=timeout)
|
|
66
|
+
timeout=timeout, stdin=stdin)
|
|
67
67
|
except subprocess.TimeoutExpired:
|
|
68
68
|
return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
|
|
69
69
|
|
|
@@ -743,6 +743,84 @@ def collect_luv_env() -> dict[str, str]:
|
|
|
743
743
|
return result
|
|
744
744
|
|
|
745
745
|
|
|
746
|
+
# An rc file is a program, and some of them never finish: one that starts tmux,
|
|
747
|
+
# or waits on a prompt, would otherwise hang every session start.
|
|
748
|
+
SHELL_ENV_TIMEOUT = 15
|
|
749
|
+
|
|
750
|
+
# Vars that describe the shell we just ran rather than the setup we asked it
|
|
751
|
+
# about. Importing them would tell the agent it is somewhere it is not.
|
|
752
|
+
SHELL_ENV_SKIP = {"_", "OLDPWD", "PWD", "SHLVL"}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def shell_env() -> dict[str, str]:
|
|
756
|
+
"""Everything the user's login+interactive shell exports.
|
|
757
|
+
|
|
758
|
+
tmux and ssh exec their command directly, with no shell in between, so a
|
|
759
|
+
session luv started — a remote dispatch, a handover, a detached start —
|
|
760
|
+
never sources ~/.bashrc or ~/.zshrc. It runs with whatever environment the
|
|
761
|
+
tmux server was started with, frozen at whenever that server first came up.
|
|
762
|
+
The API key an agent authenticates with and the PATH entry that makes
|
|
763
|
+
'codex' resolvable both live in the rc, and both go missing.
|
|
764
|
+
|
|
765
|
+
Asking $SHELL for its own environment is the only way to get them back: rc
|
|
766
|
+
files are code, not data, and there is no reading them from outside. -l
|
|
767
|
+
covers the profile files, -i the rc files; between them they reach where
|
|
768
|
+
bash and zsh users actually put their exports.
|
|
769
|
+
"""
|
|
770
|
+
shell = os.environ.get("SHELL") or "/bin/bash"
|
|
771
|
+
marker = "__luv_env__"
|
|
772
|
+
code = ("import json,os,sys;"
|
|
773
|
+
f"sys.stdout.write({marker!r} + json.dumps(dict(os.environ)))")
|
|
774
|
+
probe = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
|
|
775
|
+
# DEVNULL because an interactive shell sharing our tty would fight the
|
|
776
|
+
# agent for it, and lose to SIGTTOU rather than to the timeout.
|
|
777
|
+
r = run([shell, "-lic", probe], timeout=SHELL_ENV_TIMEOUT,
|
|
778
|
+
stdin=subprocess.DEVNULL)
|
|
779
|
+
# Match on the marker, not the exit status: rc files greet, warn, and fail
|
|
780
|
+
# in ways that say nothing about whether the environment came back. What
|
|
781
|
+
# precedes the marker is the greeting; raw_decode drops anything an exit
|
|
782
|
+
# hook prints after it.
|
|
783
|
+
if marker not in r.stdout:
|
|
784
|
+
return {}
|
|
785
|
+
try:
|
|
786
|
+
env, _ = json.JSONDecoder().raw_decode(r.stdout.split(marker, 1)[1].lstrip())
|
|
787
|
+
except ValueError:
|
|
788
|
+
return {}
|
|
789
|
+
return {k: v for k, v in env.items() if isinstance(v, str)}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def merged_path(current: str, from_rc: str) -> str:
|
|
793
|
+
"""`current` first, then whatever the rc adds. PATH order is a precedence
|
|
794
|
+
list, so the binaries that got us this far keep winning."""
|
|
795
|
+
entries = [p for p in current.split(os.pathsep) if p]
|
|
796
|
+
entries += [p for p in from_rc.split(os.pathsep) if p and p not in entries]
|
|
797
|
+
return os.pathsep.join(entries)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def apply_shell_env() -> None:
|
|
801
|
+
"""Fill this process's environment in from the user's shell.
|
|
802
|
+
|
|
803
|
+
Only for the luv that tmux or ssh started (_LUV_INNER). A luv you ran
|
|
804
|
+
yourself already inherited the shell it ran in; re-deriving it there would
|
|
805
|
+
charge every session start for an rc that has already run.
|
|
806
|
+
|
|
807
|
+
What is already set wins. 'FOO=bar luv …' and -e are statements about this
|
|
808
|
+
session, while the rc is the background default, and a session that quietly
|
|
809
|
+
overrode the value you handed it would be worse than one missing it. PATH
|
|
810
|
+
is the exception, being a list rather than a value: the rc's entries are
|
|
811
|
+
appended, which is how nvm's node and ~/.local/bin become findable.
|
|
812
|
+
"""
|
|
813
|
+
if not os.environ.get("_LUV_INNER") or load_config().get("shell_env") is False:
|
|
814
|
+
return
|
|
815
|
+
for key, value in shell_env().items():
|
|
816
|
+
if key in SHELL_ENV_SKIP:
|
|
817
|
+
continue
|
|
818
|
+
if key == "PATH":
|
|
819
|
+
os.environ["PATH"] = merged_path(os.environ.get("PATH", ""), value)
|
|
820
|
+
elif key not in os.environ:
|
|
821
|
+
os.environ[key] = value
|
|
822
|
+
|
|
823
|
+
|
|
746
824
|
def docker_env_flags(env_vars: dict[str, str]) -> list[str]:
|
|
747
825
|
"""Convert env dict to docker compose exec -e flags."""
|
|
748
826
|
flags: list[str] = []
|
|
@@ -1585,7 +1663,36 @@ def terminal_guard():
|
|
|
1585
1663
|
os.write(fd, TERM_RESET.encode())
|
|
1586
1664
|
|
|
1587
1665
|
|
|
1588
|
-
def
|
|
1666
|
+
def continue_hint(repo: str | None, workspace: str | None = None) -> str:
|
|
1667
|
+
"""The 'luv continue …' line to hand back when a session breaks.
|
|
1668
|
+
|
|
1669
|
+
As narrow as what we actually know, and no narrower: the number is read off
|
|
1670
|
+
the workspace folder, which a session dispatched before the remote picked
|
|
1671
|
+
one doesn't have yet. Every shorter form is still a command that works —
|
|
1672
|
+
a repo alone takes its newest session, and bare 'luv continue' asks.
|
|
1673
|
+
"""
|
|
1674
|
+
parts = ["luv", "continue"]
|
|
1675
|
+
if repo:
|
|
1676
|
+
parts.append(repo)
|
|
1677
|
+
number = workspace_number(repo, workspace or "")
|
|
1678
|
+
if number is not None:
|
|
1679
|
+
parts.append(str(number))
|
|
1680
|
+
return " ".join(parts)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def reopen_hint(args: list[str]) -> str:
|
|
1684
|
+
"""The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
|
|
1685
|
+
|
|
1686
|
+
Takes the same [<repo> [number]] grammar 'luv continue' does, so whatever
|
|
1687
|
+
was just typed carries straight over to the command that still works.
|
|
1688
|
+
"""
|
|
1689
|
+
repo = args[0].rstrip("/").rsplit("/", 1)[-1]
|
|
1690
|
+
number = args[1] if len(args) > 1 and args[1].isdigit() else None
|
|
1691
|
+
return " ".join(["luv", repo] + ([number] if number else []) + ["-r"])
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def hand_over(argv: list[str], *, restore: bool = True, watch=None,
|
|
1695
|
+
hint: str | None = None) -> None:
|
|
1589
1696
|
"""Give the terminal to a child and exit with its status. Never returns.
|
|
1590
1697
|
|
|
1591
1698
|
Without `restore` this is a plain execv, which is the better deal when
|
|
@@ -1604,6 +1711,12 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
|
|
|
1604
1711
|
the agent starts ten minutes in. It runs on a daemon thread and touches
|
|
1605
1712
|
neither the terminal nor termios — this path is the one that has to stay
|
|
1606
1713
|
boring.
|
|
1714
|
+
|
|
1715
|
+
`hint` is the way back in, printed when the child exits badly. A clean exit
|
|
1716
|
+
is either a detach or the agent finishing, and neither wants advice; a bad
|
|
1717
|
+
one is usually ssh losing the connection out from under a session that is
|
|
1718
|
+
still running on the other side, and the terminal it lands back in should
|
|
1719
|
+
not have to be told twice how to get there.
|
|
1607
1720
|
"""
|
|
1608
1721
|
if not restore:
|
|
1609
1722
|
os.execv(argv[0], argv)
|
|
@@ -1620,19 +1733,25 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
|
|
|
1620
1733
|
# The child got this same Ctrl-C from the tty and decides
|
|
1621
1734
|
# for itself what to do with it; outliving it is the point.
|
|
1622
1735
|
continue
|
|
1736
|
+
if code != 0 and hint:
|
|
1737
|
+
# After the guard, not inside it: a terminal still in the remote
|
|
1738
|
+
# program's modes is no place to print something to be copied.
|
|
1739
|
+
print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
|
|
1740
|
+
f" {hint}\n", file=sys.stderr)
|
|
1623
1741
|
sys.exit(code)
|
|
1624
1742
|
|
|
1625
1743
|
|
|
1626
|
-
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None
|
|
1744
|
+
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
|
|
1745
|
+
hint: str | None = None) -> None:
|
|
1627
1746
|
"""Hand the terminal to ssh. Never returns."""
|
|
1628
1747
|
ssh_bin = shutil.which("ssh")
|
|
1629
1748
|
if not ssh_bin:
|
|
1630
1749
|
die("'ssh' not found in PATH")
|
|
1631
1750
|
argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
|
|
1632
|
-
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch)
|
|
1751
|
+
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
|
|
1633
1752
|
|
|
1634
1753
|
|
|
1635
|
-
def attach_session(hc: dict | None, name: str) -> None:
|
|
1754
|
+
def attach_session(hc: dict | None, name: str, *, hint: str | None = None) -> None:
|
|
1636
1755
|
"""Attach to a tmux session, locally or over ssh. Never returns.
|
|
1637
1756
|
|
|
1638
1757
|
-d detaches other clients so the pane isn't size-clamped to a stale window
|
|
@@ -1642,11 +1761,11 @@ def attach_session(hc: dict | None, name: str) -> None:
|
|
|
1642
1761
|
tmux_bin = shutil.which("tmux")
|
|
1643
1762
|
if not tmux_bin:
|
|
1644
1763
|
die("'tmux' not found in PATH")
|
|
1645
|
-
hand_over([tmux_bin, "attach", "-d", "-t", name])
|
|
1764
|
+
hand_over([tmux_bin, "attach", "-d", "-t", name], hint=hint)
|
|
1646
1765
|
return
|
|
1647
1766
|
print(f"luv: attaching {name} on {hc['host']}")
|
|
1648
1767
|
exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]),
|
|
1649
|
-
watch=port_watch(hc, session=name))
|
|
1768
|
+
watch=port_watch(hc, session=name), hint=hint)
|
|
1650
1769
|
|
|
1651
1770
|
|
|
1652
1771
|
def remote_prompt(args: list[str]) -> str | None:
|
|
@@ -1758,14 +1877,18 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
|
|
|
1758
1877
|
record_session({**meta, "id": sid, "host": hc["host"], "session": session,
|
|
1759
1878
|
"workspace": workspace, "created": int(time.time())})
|
|
1760
1879
|
|
|
1880
|
+
# Only a tmux session is there to come back to; -nit and --clean run to
|
|
1881
|
+
# completion over ssh and have nothing to continue.
|
|
1882
|
+
hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
|
|
1761
1883
|
print(f"luv: {hc['host']} — {session or 'no tmux'}")
|
|
1762
1884
|
if detach:
|
|
1763
1885
|
r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
|
|
1764
1886
|
if r.returncode != 0:
|
|
1765
1887
|
die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
|
|
1766
|
-
print(f"luv: started detached — 'luv continue'
|
|
1888
|
+
print(f"luv: started detached — attach with: {hint or 'luv continue'}")
|
|
1767
1889
|
return
|
|
1768
|
-
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session)
|
|
1890
|
+
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
|
|
1891
|
+
hint=hint)
|
|
1769
1892
|
|
|
1770
1893
|
|
|
1771
1894
|
def cmd_paths() -> None:
|
|
@@ -3145,14 +3268,21 @@ def cmd_continue(args: list[str], identity: str | None = None) -> None:
|
|
|
3145
3268
|
live, label = filter_sessions([s for s in sessions if s.get("live")], args)
|
|
3146
3269
|
|
|
3147
3270
|
if not live:
|
|
3148
|
-
|
|
3271
|
+
# This is where a hint printed after a crash lands when the agent took
|
|
3272
|
+
# the tmux session down with it, so send it on rather than stopping at
|
|
3273
|
+
# the bad news: the workspace outlives the session, and -r reopens it.
|
|
3274
|
+
named = bool(args) and not args[0].startswith("-")
|
|
3275
|
+
die(f"no live luv sessions{label}"
|
|
3276
|
+
+ (f" — if its workspace is still there: {reopen_hint(args)}"
|
|
3277
|
+
if named else ""))
|
|
3149
3278
|
live.sort(key=session_sort_key, reverse=True)
|
|
3150
3279
|
|
|
3151
3280
|
# An explicit repo means "the newest one for it".
|
|
3152
3281
|
target = live[0] if (len(live) == 1 or args) else choose_session(live)
|
|
3153
3282
|
|
|
3154
3283
|
host = target.get("host")
|
|
3155
|
-
attach_session(resolve_host(host, identity) if host else None, target["session"]
|
|
3284
|
+
attach_session(resolve_host(host, identity) if host else None, target["session"],
|
|
3285
|
+
hint=continue_hint(target.get("repo"), target.get("workspace")))
|
|
3156
3286
|
|
|
3157
3287
|
|
|
3158
3288
|
def start_local_session(workspace: str, luv_args: list[str], meta: dict,
|
|
@@ -3170,13 +3300,14 @@ def start_local_session(workspace: str, luv_args: list[str], meta: dict,
|
|
|
3170
3300
|
shutil.which("luv") or "luv"] + luv_args
|
|
3171
3301
|
argv = [tmux_bin, "new-session"] + ([] if attach else ["-d"]) + \
|
|
3172
3302
|
["-A", "-s", session, "--"] + inner
|
|
3303
|
+
hint = continue_hint(meta.get("repo"), workspace)
|
|
3173
3304
|
print(f"luv: local — {session}")
|
|
3174
3305
|
if attach:
|
|
3175
|
-
|
|
3306
|
+
hand_over(argv, hint=hint)
|
|
3176
3307
|
r = subprocess.run(argv)
|
|
3177
3308
|
if r.returncode != 0:
|
|
3178
3309
|
die(f"could not start {session}")
|
|
3179
|
-
print("luv: started detached —
|
|
3310
|
+
print(f"luv: started detached — attach with: {hint}")
|
|
3180
3311
|
|
|
3181
3312
|
|
|
3182
3313
|
def workspace_origin(hc: dict | None, ws: Path) -> tuple[str, str] | None:
|
|
@@ -3404,6 +3535,11 @@ def main() -> None:
|
|
|
3404
3535
|
die("--local cannot be combined with -s or -i")
|
|
3405
3536
|
|
|
3406
3537
|
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe", "--claude", "--codex", "--local", "--no-agent-state", "--no-attach", "--purge", "-y")]
|
|
3538
|
+
|
|
3539
|
+
# Before anything shells out: the clone needs gh and git on PATH as much as
|
|
3540
|
+
# the agent needs its credentials, and both come from the same rc. Ahead of
|
|
3541
|
+
# collect_luv_env() too, so a LUV_* exported there is one -e can forward.
|
|
3542
|
+
apply_shell_env()
|
|
3407
3543
|
extra_env = collect_luv_env() if env_mode else {}
|
|
3408
3544
|
|
|
3409
3545
|
# _LUV_INNER marks the remote-side luv: it must never dispatch onward.
|
|
@@ -3471,7 +3607,8 @@ Remote:
|
|
|
3471
3607
|
Once 'luv config' has a remote host, every workspace command runs there inside
|
|
3472
3608
|
a tmux session that survives disconnects. 'luv ls' shows what is running on
|
|
3473
3609
|
every host — including sessions started from another machine — and
|
|
3474
|
-
'luv continue' reattaches.
|
|
3610
|
+
'luv continue' reattaches. A session that ends badly prints the exact
|
|
3611
|
+
'luv continue <repo> <n>' for itself. Use --local for a one-off local run.
|
|
3475
3612
|
'luv handover' moves a running session — workspace, uncommitted work, and the
|
|
3476
3613
|
agent's conversation — to another machine, then resumes it there.
|
|
3477
3614
|
Requires luv, tmux, gh and git on the remote. See docs/remote-sessions.md.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "MIT"
|
|
@@ -387,6 +387,142 @@ class TerminalRestoreTests(unittest.TestCase):
|
|
|
387
387
|
self.assertNotEqual(hand_over.call_args.kwargs.get("restore"), False)
|
|
388
388
|
|
|
389
389
|
|
|
390
|
+
class _OrderedStderr(io.StringIO):
|
|
391
|
+
"""A stderr that notes where its first write falls in a sequence."""
|
|
392
|
+
|
|
393
|
+
def __init__(self, order):
|
|
394
|
+
super().__init__()
|
|
395
|
+
self.order = order
|
|
396
|
+
|
|
397
|
+
def write(self, s):
|
|
398
|
+
if s.strip() and "hint" not in self.order:
|
|
399
|
+
self.order.append("hint")
|
|
400
|
+
return super().write(s)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class ContinueHintTests(unittest.TestCase):
|
|
404
|
+
"""A session that breaks must say how to get back into it, in one line
|
|
405
|
+
that can be copied off the terminal and run as-is."""
|
|
406
|
+
|
|
407
|
+
FD = TerminalRestoreTests.FD
|
|
408
|
+
SAVED = TerminalRestoreTests.SAVED
|
|
409
|
+
HINT = "luv continue myrepo 42"
|
|
410
|
+
|
|
411
|
+
def _hand_over(self, stderr, **kwargs):
|
|
412
|
+
"""Run hand_over over a fake child, with the terminal all stubbed out."""
|
|
413
|
+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
|
|
414
|
+
patch.object(luv.subprocess, "Popen",
|
|
415
|
+
return_value=_FakeProc(kwargs.pop("returncode", 0))),
|
|
416
|
+
patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
|
|
417
|
+
patch.object(luv.termios, "tcsetattr"),
|
|
418
|
+
patch.object(luv.os, "write", side_effect=self.on_write),
|
|
419
|
+
contextlib.redirect_stderr(stderr),
|
|
420
|
+
self.assertRaises(SystemExit) as exit_ctx):
|
|
421
|
+
luv.hand_over(["/bin/ssh", "box"], **kwargs)
|
|
422
|
+
return exit_ctx.exception.code
|
|
423
|
+
|
|
424
|
+
def on_write(self, fd, data):
|
|
425
|
+
return len(data)
|
|
426
|
+
|
|
427
|
+
def test_broken_session_hands_back_a_runnable_command(self):
|
|
428
|
+
err = io.StringIO()
|
|
429
|
+
code = self._hand_over(err, returncode=255, hint=self.HINT)
|
|
430
|
+
|
|
431
|
+
self.assertEqual(code, 255)
|
|
432
|
+
self.assertIn(self.HINT, err.getvalue())
|
|
433
|
+
# Copy-pasteable means the whole command sits on a line of its own, with
|
|
434
|
+
# nothing but indentation around it to select past.
|
|
435
|
+
line = next(l for l in err.getvalue().splitlines() if self.HINT in l)
|
|
436
|
+
self.assertEqual(line.strip(), self.HINT)
|
|
437
|
+
|
|
438
|
+
def test_a_clean_exit_says_nothing(self):
|
|
439
|
+
# Detaching and the agent finishing both land here; neither is broken.
|
|
440
|
+
err = io.StringIO()
|
|
441
|
+
code = self._hand_over(err, returncode=0, hint=self.HINT)
|
|
442
|
+
|
|
443
|
+
self.assertEqual(code, 0)
|
|
444
|
+
self.assertNotIn("luv continue", err.getvalue())
|
|
445
|
+
|
|
446
|
+
def test_the_hint_waits_for_the_terminal_to_be_restored(self):
|
|
447
|
+
# Printed inside the guard it would land in the remote program's modes,
|
|
448
|
+
# which is where unreadable output comes from in the first place.
|
|
449
|
+
order = []
|
|
450
|
+
self.on_write = lambda fd, data: order.append("reset")
|
|
451
|
+
self._hand_over(_OrderedStderr(order), returncode=255, hint=self.HINT)
|
|
452
|
+
|
|
453
|
+
self.assertEqual(order, ["reset", "hint"])
|
|
454
|
+
|
|
455
|
+
def test_hint_names_the_repo_and_number_it_knows(self):
|
|
456
|
+
self.assertEqual(luv.continue_hint("myrepo", "myrepo-box-42"),
|
|
457
|
+
"luv continue myrepo 42")
|
|
458
|
+
self.assertEqual(luv.continue_hint("myrepo", "myrepo-42"),
|
|
459
|
+
"luv continue myrepo 42", "pre-slug folders too")
|
|
460
|
+
|
|
461
|
+
def test_hint_degrades_to_what_is_known(self):
|
|
462
|
+
# A brand-new workspace has no number until the remote picks one, and an
|
|
463
|
+
# adopted session may not even have a repo. Both shorter forms still run.
|
|
464
|
+
self.assertEqual(luv.continue_hint("myrepo", "luv-pending-abc123"),
|
|
465
|
+
"luv continue myrepo")
|
|
466
|
+
self.assertEqual(luv.continue_hint("myrepo", None), "luv continue myrepo")
|
|
467
|
+
self.assertEqual(luv.continue_hint(None, "myrepo-box-42"), "luv continue")
|
|
468
|
+
|
|
469
|
+
def test_remote_session_carries_its_own_hint(self):
|
|
470
|
+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
471
|
+
patch.object(luv, "record_session"),
|
|
472
|
+
patch.object(luv, "port_watch", return_value=None),
|
|
473
|
+
patch.object(luv, "hand_over") as hand_over,
|
|
474
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
475
|
+
luv.dispatch_remote({"host": "box"}, ["myrepo", "42"],
|
|
476
|
+
workspace="myrepo-box-42",
|
|
477
|
+
meta={"repo": "myrepo", "org": "acme"})
|
|
478
|
+
|
|
479
|
+
self.assertEqual(hand_over.call_args.kwargs.get("hint"),
|
|
480
|
+
"luv continue myrepo 42")
|
|
481
|
+
|
|
482
|
+
def test_a_run_with_no_session_behind_it_gets_no_hint(self):
|
|
483
|
+
# -nit streams to a pipe and exits; there is nothing to continue.
|
|
484
|
+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
|
|
485
|
+
patch.object(luv, "port_watch", return_value=None),
|
|
486
|
+
patch.object(luv, "hand_over") as hand_over,
|
|
487
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
488
|
+
luv.dispatch_remote({"host": "box"}, ["myrepo", "42", "-nit"],
|
|
489
|
+
use_tmux=False, tty=False)
|
|
490
|
+
|
|
491
|
+
self.assertIsNone(hand_over.call_args.kwargs.get("hint"))
|
|
492
|
+
|
|
493
|
+
def test_attaching_carries_the_hint_for_the_session_it_picked(self):
|
|
494
|
+
row = {"host": "box", "session": "luv-myrepo-42", "live": True,
|
|
495
|
+
"repo": "myrepo", "workspace": "myrepo-box-42"}
|
|
496
|
+
with (patch.object(luv, "refresh_sessions", return_value=([row], set())),
|
|
497
|
+
patch.object(luv, "resolve_host", return_value={"host": "box"}),
|
|
498
|
+
patch.object(luv, "attach_session") as attach,
|
|
499
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
500
|
+
luv.cmd_continue(["myrepo", "42"])
|
|
501
|
+
|
|
502
|
+
self.assertEqual(attach.call_args.kwargs.get("hint"),
|
|
503
|
+
"luv continue myrepo 42")
|
|
504
|
+
|
|
505
|
+
def test_a_session_that_is_already_gone_points_at_the_reopen(self):
|
|
506
|
+
# Where the hint lands when the agent took the tmux session down with
|
|
507
|
+
# it: the workspace outlives the session, so pass the user on.
|
|
508
|
+
with (patch.object(luv, "refresh_sessions", return_value=([], set())),
|
|
509
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
510
|
+
with self.assertRaises(SystemExit):
|
|
511
|
+
with contextlib.redirect_stderr(io.StringIO()) as err:
|
|
512
|
+
luv.cmd_continue(["myrepo", "42"])
|
|
513
|
+
|
|
514
|
+
self.assertIn("luv myrepo 42 -r", err.getvalue())
|
|
515
|
+
|
|
516
|
+
def test_nothing_live_at_all_suggests_nothing(self):
|
|
517
|
+
with (patch.object(luv, "refresh_sessions", return_value=([], set())),
|
|
518
|
+
contextlib.redirect_stdout(io.StringIO())):
|
|
519
|
+
with self.assertRaises(SystemExit):
|
|
520
|
+
with contextlib.redirect_stderr(io.StringIO()) as err:
|
|
521
|
+
luv.cmd_continue([])
|
|
522
|
+
|
|
523
|
+
self.assertNotIn("-r", err.getvalue())
|
|
524
|
+
|
|
525
|
+
|
|
390
526
|
class SessionNameTests(unittest.TestCase):
|
|
391
527
|
def test_illegal_tmux_characters_are_replaced(self):
|
|
392
528
|
self.assertEqual(luv.tmux_session_name("foo.js-7"), "luv-foo_js-7")
|
|
@@ -676,6 +812,102 @@ class ConfigTests(unittest.TestCase):
|
|
|
676
812
|
self.assertIn("ConnectTimeout=5", base)
|
|
677
813
|
|
|
678
814
|
|
|
815
|
+
def _shell_env_output(env, before="", after=""):
|
|
816
|
+
"""What the probe sees: an rc's greeting, the marker, the environment."""
|
|
817
|
+
return f"{before}__luv_env__{json.dumps(env)}{after}"
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
class ShellEnvTests(unittest.TestCase):
|
|
821
|
+
"""tmux and ssh exec without a shell, so the rc has to be asked for."""
|
|
822
|
+
|
|
823
|
+
def _probe(self, stdout, returncode=0):
|
|
824
|
+
with (patch.dict(luv.os.environ, {"SHELL": "/bin/zsh"}, clear=True),
|
|
825
|
+
patch.object(luv, "run",
|
|
826
|
+
return_value=_completed(stdout, returncode)) as run):
|
|
827
|
+
return luv.shell_env(), run.call_args
|
|
828
|
+
|
|
829
|
+
def test_asks_the_users_own_shell_as_login_and_interactive(self):
|
|
830
|
+
env, call = self._probe(_shell_env_output({"API_BASE": "https://x"}))
|
|
831
|
+
|
|
832
|
+
self.assertEqual(env, {"API_BASE": "https://x"})
|
|
833
|
+
self.assertEqual(call.args[0][:2], ["/bin/zsh", "-lic"])
|
|
834
|
+
self.assertEqual(call.kwargs["stdin"], subprocess.DEVNULL)
|
|
835
|
+
self.assertEqual(call.kwargs["timeout"], luv.SHELL_ENV_TIMEOUT)
|
|
836
|
+
|
|
837
|
+
def test_greeting_before_the_marker_is_ignored(self):
|
|
838
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}, before="welcome back!\n"))
|
|
839
|
+
|
|
840
|
+
self.assertEqual(env, {"A": "1"})
|
|
841
|
+
|
|
842
|
+
def test_exit_hook_after_the_environment_is_ignored(self):
|
|
843
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}, after="\ngoodbye\n"))
|
|
844
|
+
|
|
845
|
+
self.assertEqual(env, {"A": "1"})
|
|
846
|
+
|
|
847
|
+
def test_failing_rc_still_yields_what_it_exported(self):
|
|
848
|
+
env, _ = self._probe(_shell_env_output({"A": "1"}), returncode=1)
|
|
849
|
+
|
|
850
|
+
self.assertEqual(env, {"A": "1"})
|
|
851
|
+
|
|
852
|
+
def test_a_shell_that_says_nothing_useful_is_not_an_error(self):
|
|
853
|
+
self.assertEqual(self._probe("")[0], {})
|
|
854
|
+
self.assertEqual(self._probe("__luv_env__not json")[0], {})
|
|
855
|
+
|
|
856
|
+
def test_timeout_is_not_an_error(self):
|
|
857
|
+
# run() turns a hung rc into returncode 124 rather than an exception.
|
|
858
|
+
self.assertEqual(self._probe("", returncode=124)[0], {})
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
class ApplyShellEnvTests(unittest.TestCase):
|
|
862
|
+
def _apply(self, environ, rc, config=None):
|
|
863
|
+
with (patch.dict(luv.os.environ, environ, clear=True),
|
|
864
|
+
patch.object(luv, "load_config", return_value=config or {}),
|
|
865
|
+
patch.object(luv, "shell_env", return_value=rc) as probe):
|
|
866
|
+
luv.apply_shell_env()
|
|
867
|
+
return dict(luv.os.environ), probe.called
|
|
868
|
+
|
|
869
|
+
def test_only_runs_for_the_luv_tmux_or_ssh_started(self):
|
|
870
|
+
env, probed = self._apply({}, {"API_KEY": "from-rc"})
|
|
871
|
+
|
|
872
|
+
self.assertFalse(probed)
|
|
873
|
+
self.assertNotIn("API_KEY", env)
|
|
874
|
+
|
|
875
|
+
def test_fills_in_what_the_session_never_sourced(self):
|
|
876
|
+
env, _ = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"})
|
|
877
|
+
|
|
878
|
+
self.assertEqual(env["API_KEY"], "from-rc")
|
|
879
|
+
|
|
880
|
+
def test_what_the_caller_set_wins(self):
|
|
881
|
+
env, _ = self._apply({"_LUV_INNER": "1", "API_KEY": "explicit"},
|
|
882
|
+
{"API_KEY": "from-rc", "OTHER": "from-rc"})
|
|
883
|
+
|
|
884
|
+
self.assertEqual(env["API_KEY"], "explicit")
|
|
885
|
+
self.assertEqual(env["OTHER"], "from-rc")
|
|
886
|
+
|
|
887
|
+
def test_path_is_merged_rather_than_replaced(self):
|
|
888
|
+
env, _ = self._apply({"_LUV_INNER": "1", "PATH": "/usr/bin:/bin"},
|
|
889
|
+
{"PATH": "/home/u/.nvm/bin:/usr/bin:/home/u/.local/bin"})
|
|
890
|
+
|
|
891
|
+
self.assertEqual(env["PATH"],
|
|
892
|
+
"/usr/bin:/bin:/home/u/.nvm/bin:/home/u/.local/bin")
|
|
893
|
+
|
|
894
|
+
def test_the_probe_shells_own_bookkeeping_is_left_behind(self):
|
|
895
|
+
env, _ = self._apply({"_LUV_INNER": "1"},
|
|
896
|
+
{"PWD": "/home/u", "SHLVL": "3", "_": "/usr/bin/env",
|
|
897
|
+
"OLDPWD": "/tmp", "KEEP": "yes"})
|
|
898
|
+
|
|
899
|
+
self.assertEqual(env["KEEP"], "yes")
|
|
900
|
+
for key in ("PWD", "SHLVL", "_", "OLDPWD"):
|
|
901
|
+
self.assertNotIn(key, env)
|
|
902
|
+
|
|
903
|
+
def test_config_can_turn_it_off(self):
|
|
904
|
+
env, probed = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"},
|
|
905
|
+
config={"shell_env": False})
|
|
906
|
+
|
|
907
|
+
self.assertFalse(probed)
|
|
908
|
+
self.assertNotIn("API_KEY", env)
|
|
909
|
+
|
|
910
|
+
|
|
679
911
|
class CleanGuardTests(unittest.TestCase):
|
|
680
912
|
def test_live_session_folder_is_skipped(self):
|
|
681
913
|
tempdir = tempfile.TemporaryDirectory()
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
name: Publish to PyPI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- "v*"
|
|
7
|
-
|
|
8
|
-
permissions:
|
|
9
|
-
id-token: write
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
publish:
|
|
13
|
-
runs-on: ubuntu-latest
|
|
14
|
-
environment: pypi
|
|
15
|
-
steps:
|
|
16
|
-
- uses: actions/checkout@v4
|
|
17
|
-
|
|
18
|
-
- uses: actions/setup-python@v5
|
|
19
|
-
with:
|
|
20
|
-
python-version: "3.12"
|
|
21
|
-
|
|
22
|
-
- name: Install build tools
|
|
23
|
-
run: pip install build
|
|
24
|
-
|
|
25
|
-
- name: Build package
|
|
26
|
-
run: python -m build
|
|
27
|
-
|
|
28
|
-
- name: Publish to PyPI
|
|
29
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|