luv-cli 0.0.22__tar.gz → 0.0.24__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.0.22 → luv_cli-0.0.24}/PKG-INFO +27 -13
- {luv_cli-0.0.22 → luv_cli-0.0.24}/README.md +24 -10
- luv_cli-0.0.24/claude-output.txt +0 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/luv/__init__.py +161 -80
- {luv_cli-0.0.22 → luv_cli-0.0.24}/pyproject.toml +3 -3
- luv_cli-0.0.24/tests/__init__.py +0 -0
- luv_cli-0.0.24/tests/test_agents.py +74 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/.claude/settings.json +0 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/.gitignore +0 -0
- {luv_cli-0.0.22 → luv_cli-0.0.24}/LICENSE +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.0.
|
|
4
|
-
Summary: Launch Claude Code agents on GitHub repos with isolated workspaces and optional Docker dev environments
|
|
3
|
+
Version: 0.0.24
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/exospherehost/luv/issues
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Keywords: agent,ai,claude,cli,docker,github
|
|
10
|
+
Keywords: agent,ai,claude,cli,codex,docker,github
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
|
|
24
24
|
# luv
|
|
25
25
|
|
|
26
|
-
A CLI that launches
|
|
26
|
+
A CLI that launches Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments.
|
|
27
27
|
|
|
28
28
|
`luv` clones a repo, creates a branch, and drops you into a Claude session ready to work. When the repo ships a `.luv/settings.json`, it spins up Docker Compose automatically so every command runs in the right environment.
|
|
29
29
|
|
|
@@ -37,7 +37,7 @@ uv tool install luv-cli
|
|
|
37
37
|
pip install luv-cli
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
**Requirements:** [
|
|
40
|
+
**Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) plus either Claude Code or Codex must be installed and authenticated.
|
|
41
41
|
|
|
42
42
|
## Quick start
|
|
43
43
|
|
|
@@ -48,6 +48,15 @@ luv --init
|
|
|
48
48
|
# Create a new workspace and launch Claude
|
|
49
49
|
luv my-repo "add user authentication"
|
|
50
50
|
|
|
51
|
+
# Launch Codex in YOLO mode instead
|
|
52
|
+
luv --codex my-repo "add user authentication"
|
|
53
|
+
|
|
54
|
+
# Select Claude explicitly (Claude remains the default)
|
|
55
|
+
luv --claude my-repo "add user authentication"
|
|
56
|
+
|
|
57
|
+
# Base a new workspace off a non-default branch
|
|
58
|
+
luv my-repo -b develop "add user authentication"
|
|
59
|
+
|
|
51
60
|
# Use a different org inline
|
|
52
61
|
luv other-org/my-repo "fix the bug"
|
|
53
62
|
|
|
@@ -71,8 +80,8 @@ luv --clean
|
|
|
71
80
|
|
|
72
81
|
1. Clones the repo into `~/prs/{repo}-{number}/`
|
|
73
82
|
2. Creates a new branch `luv-{number}`
|
|
74
|
-
3.
|
|
75
|
-
4. Launches Claude with Opus 4.8 at max effort
|
|
83
|
+
3. Configures the selected agent with the workspace's PR conventions
|
|
84
|
+
4. Launches Claude with Opus 4.8 at max effort, or Codex in YOLO mode
|
|
76
85
|
|
|
77
86
|
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
|
|
78
87
|
|
|
@@ -81,7 +90,9 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
81
90
|
| Command | Description |
|
|
82
91
|
|---------|-------------|
|
|
83
92
|
| `luv --init` | Configure default GitHub org |
|
|
84
|
-
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude |
|
|
93
|
+
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
|
|
94
|
+
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
|
|
95
|
+
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
85
96
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
86
97
|
| `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
|
|
87
98
|
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
|
|
@@ -93,11 +104,14 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
93
104
|
|
|
94
105
|
| Flag | Description |
|
|
95
106
|
|------|-------------|
|
|
107
|
+
| `--claude` | Launch Claude Code (default) |
|
|
108
|
+
| `--codex` | Launch Codex with approvals and sandboxing bypassed (YOLO mode) |
|
|
96
109
|
| `-n` | Navigate: open a shell instead of Claude |
|
|
97
|
-
| `-r` | Resume: resume the last
|
|
110
|
+
| `-r` | Resume: resume the selected agent's last session |
|
|
98
111
|
| `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
|
|
99
|
-
| `-nit` | Non-interactive: run
|
|
100
|
-
| `-m MODEL` |
|
|
112
|
+
| `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
|
|
113
|
+
| `-m MODEL` | Model to use; Claude defaults to `claude-opus-4-8`, while Codex uses its configured CLI default |
|
|
114
|
+
| `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
|
|
101
115
|
| `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
|
|
102
116
|
| `-f`, `--force` | Skip safety checks (with `--clean`) |
|
|
103
117
|
| `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
|
|
@@ -138,7 +152,7 @@ services:
|
|
|
138
152
|
POSTGRES_PASSWORD: dev
|
|
139
153
|
```
|
|
140
154
|
|
|
141
|
-
The `dev-environment` service **must** have
|
|
155
|
+
The `dev-environment` service **must** have the selected agent CLI (`claude` or `codex`) installed in its image.
|
|
142
156
|
|
|
143
157
|
### How Docker mode works
|
|
144
158
|
|
|
@@ -146,7 +160,7 @@ The `dev-environment` service **must** have [Claude Code](https://docs.anthropic
|
|
|
146
160
|
2. Tears down any stale environment from a previous run
|
|
147
161
|
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
|
|
148
162
|
4. Verifies the `dev-environment` service is running
|
|
149
|
-
5. Runs
|
|
163
|
+
5. Runs the selected agent inside the container via `docker compose exec`
|
|
150
164
|
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
|
|
151
165
|
7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
|
|
152
166
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# luv
|
|
2
2
|
|
|
3
|
-
A CLI that launches
|
|
3
|
+
A CLI that launches Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments.
|
|
4
4
|
|
|
5
5
|
`luv` clones a repo, creates a branch, and drops you into a Claude session ready to work. When the repo ships a `.luv/settings.json`, it spins up Docker Compose automatically so every command runs in the right environment.
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ uv tool install luv-cli
|
|
|
14
14
|
pip install luv-cli
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
**Requirements:** [
|
|
17
|
+
**Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) plus either Claude Code or Codex must be installed and authenticated.
|
|
18
18
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
@@ -25,6 +25,15 @@ luv --init
|
|
|
25
25
|
# Create a new workspace and launch Claude
|
|
26
26
|
luv my-repo "add user authentication"
|
|
27
27
|
|
|
28
|
+
# Launch Codex in YOLO mode instead
|
|
29
|
+
luv --codex my-repo "add user authentication"
|
|
30
|
+
|
|
31
|
+
# Select Claude explicitly (Claude remains the default)
|
|
32
|
+
luv --claude my-repo "add user authentication"
|
|
33
|
+
|
|
34
|
+
# Base a new workspace off a non-default branch
|
|
35
|
+
luv my-repo -b develop "add user authentication"
|
|
36
|
+
|
|
28
37
|
# Use a different org inline
|
|
29
38
|
luv other-org/my-repo "fix the bug"
|
|
30
39
|
|
|
@@ -48,8 +57,8 @@ luv --clean
|
|
|
48
57
|
|
|
49
58
|
1. Clones the repo into `~/prs/{repo}-{number}/`
|
|
50
59
|
2. Creates a new branch `luv-{number}`
|
|
51
|
-
3.
|
|
52
|
-
4. Launches Claude with Opus 4.8 at max effort
|
|
60
|
+
3. Configures the selected agent with the workspace's PR conventions
|
|
61
|
+
4. Launches Claude with Opus 4.8 at max effort, or Codex in YOLO mode
|
|
53
62
|
|
|
54
63
|
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
|
|
55
64
|
|
|
@@ -58,7 +67,9 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
58
67
|
| Command | Description |
|
|
59
68
|
|---------|-------------|
|
|
60
69
|
| `luv --init` | Configure default GitHub org |
|
|
61
|
-
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude |
|
|
70
|
+
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
|
|
71
|
+
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
|
|
72
|
+
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
62
73
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
63
74
|
| `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
|
|
64
75
|
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
|
|
@@ -70,11 +81,14 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
70
81
|
|
|
71
82
|
| Flag | Description |
|
|
72
83
|
|------|-------------|
|
|
84
|
+
| `--claude` | Launch Claude Code (default) |
|
|
85
|
+
| `--codex` | Launch Codex with approvals and sandboxing bypassed (YOLO mode) |
|
|
73
86
|
| `-n` | Navigate: open a shell instead of Claude |
|
|
74
|
-
| `-r` | Resume: resume the last
|
|
87
|
+
| `-r` | Resume: resume the selected agent's last session |
|
|
75
88
|
| `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
|
|
76
|
-
| `-nit` | Non-interactive: run
|
|
77
|
-
| `-m MODEL` |
|
|
89
|
+
| `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
|
|
90
|
+
| `-m MODEL` | Model to use; Claude defaults to `claude-opus-4-8`, while Codex uses its configured CLI default |
|
|
91
|
+
| `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
|
|
78
92
|
| `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
|
|
79
93
|
| `-f`, `--force` | Skip safety checks (with `--clean`) |
|
|
80
94
|
| `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
|
|
@@ -115,7 +129,7 @@ services:
|
|
|
115
129
|
POSTGRES_PASSWORD: dev
|
|
116
130
|
```
|
|
117
131
|
|
|
118
|
-
The `dev-environment` service **must** have
|
|
132
|
+
The `dev-environment` service **must** have the selected agent CLI (`claude` or `codex`) installed in its image.
|
|
119
133
|
|
|
120
134
|
### How Docker mode works
|
|
121
135
|
|
|
@@ -123,7 +137,7 @@ The `dev-environment` service **must** have [Claude Code](https://docs.anthropic
|
|
|
123
137
|
2. Tears down any stale environment from a previous run
|
|
124
138
|
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
|
|
125
139
|
4. Verifies the `dev-environment` service is running
|
|
126
|
-
5. Runs
|
|
140
|
+
5. Runs the selected agent inside the container via `docker compose exec`
|
|
127
141
|
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
|
|
128
142
|
7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
|
|
129
143
|
|
|
File without changes
|
|
@@ -15,6 +15,7 @@ CONFIG_FILE = LUV_DIR / "config.json"
|
|
|
15
15
|
PRS_DIR = Path.home() / "prs"
|
|
16
16
|
CLAUDE_JSON = Path.home() / ".claude.json"
|
|
17
17
|
CLAUDE_SETTINGS_JSON = Path.home() / ".claude" / "settings.json"
|
|
18
|
+
CODEX_AGENTS_MD = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "AGENTS.md"
|
|
18
19
|
|
|
19
20
|
COLORS = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan", "default"]
|
|
20
21
|
|
|
@@ -147,13 +148,14 @@ def docker_env_flags(env_vars: dict[str, str]) -> list[str]:
|
|
|
147
148
|
return flags
|
|
148
149
|
|
|
149
150
|
|
|
150
|
-
def ensure_pr_rules() -> None:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
def ensure_pr_rules(agent: str = "claude") -> None:
|
|
152
|
+
"""Install the workspace/PR convention for the selected agent."""
|
|
153
|
+
instructions_file = (Path.home() / ".claude" / "CLAUDE.md"
|
|
154
|
+
if agent == "claude" else CODEX_AGENTS_MD)
|
|
155
|
+
instructions_file.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
existing = instructions_file.read_text() if instructions_file.exists() else ""
|
|
155
157
|
if "# Pull Request Management" not in existing:
|
|
156
|
-
with
|
|
158
|
+
with instructions_file.open("a") as f:
|
|
157
159
|
f.write(PR_RULES)
|
|
158
160
|
|
|
159
161
|
|
|
@@ -318,83 +320,106 @@ def navigate(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
|
|
|
318
320
|
|
|
319
321
|
|
|
320
322
|
def resume(clone_dir: Path, extra_env: dict[str, str] | None = None,
|
|
321
|
-
model: str = "claude
|
|
322
|
-
"""
|
|
323
|
+
model: str | None = None, agent: str = "claude") -> None:
|
|
324
|
+
"""Chdir and resume the selected agent, replacing this process."""
|
|
323
325
|
extra_env = extra_env or {}
|
|
324
|
-
|
|
326
|
+
if agent == "claude":
|
|
327
|
+
trust_project(clone_dir)
|
|
325
328
|
os.chdir(str(clone_dir))
|
|
326
329
|
settings = load_luv_settings(clone_dir)
|
|
327
330
|
compose_file = (settings or {}).get("compose_file")
|
|
328
331
|
|
|
332
|
+
if agent == "claude":
|
|
333
|
+
agent_cmd = ["claude", "--dangerously-skip-permissions",
|
|
334
|
+
"--model", model or "claude-opus-4-8",
|
|
335
|
+
"--effort", "max", "--resume",
|
|
336
|
+
"--remote-control",
|
|
337
|
+
"--remote-control-session-name-prefix", clone_dir.name]
|
|
338
|
+
else:
|
|
339
|
+
agent_cmd = ["codex", "resume", "--last",
|
|
340
|
+
"--dangerously-bypass-approvals-and-sandbox"]
|
|
341
|
+
if model:
|
|
342
|
+
agent_cmd += ["--model", model]
|
|
343
|
+
|
|
329
344
|
if compose_file:
|
|
330
345
|
project = docker_project_name(clone_dir)
|
|
331
346
|
start_docker(clone_dir, compose_file, project)
|
|
332
347
|
try:
|
|
333
348
|
base = docker_compose_base(clone_dir, compose_file, project)
|
|
334
|
-
r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env)
|
|
335
|
-
|
|
336
|
-
"--model", model,
|
|
337
|
-
"--effort", "max", "--resume",
|
|
338
|
-
"--remote-control",
|
|
339
|
-
"--remote-control-session-name-prefix", clone_dir.name])
|
|
349
|
+
r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env)
|
|
350
|
+
+ ["dev-environment"] + agent_cmd)
|
|
340
351
|
sys.exit(r.returncode)
|
|
341
352
|
finally:
|
|
342
353
|
stop_docker(clone_dir, compose_file, project)
|
|
343
354
|
else:
|
|
344
|
-
|
|
345
|
-
if not
|
|
346
|
-
die("'
|
|
355
|
+
agent_bin = shutil.which(agent)
|
|
356
|
+
if not agent_bin:
|
|
357
|
+
die(f"'{agent}' not found in PATH")
|
|
347
358
|
os.environ.update(extra_env)
|
|
348
|
-
os.execv(
|
|
349
|
-
"--model", model, "--effort", "max", "--resume",
|
|
350
|
-
"--remote-control",
|
|
351
|
-
"--remote-control-session-name-prefix", clone_dir.name])
|
|
359
|
+
os.execv(agent_bin, [agent_bin] + agent_cmd[1:])
|
|
352
360
|
|
|
353
361
|
|
|
354
362
|
def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
|
|
355
363
|
non_interactive: bool = False, extra_env: dict[str, str] | None = None,
|
|
356
|
-
model: str = "claude
|
|
357
|
-
"""
|
|
364
|
+
model: str | None = None, agent: str = "claude") -> None:
|
|
365
|
+
"""Resolve and launch the selected agent, replacing this process."""
|
|
358
366
|
extra_env = extra_env or {}
|
|
359
|
-
|
|
367
|
+
if agent == "claude":
|
|
368
|
+
trust_project(clone_dir)
|
|
360
369
|
os.chdir(str(clone_dir))
|
|
361
370
|
settings = load_luv_settings(clone_dir)
|
|
362
371
|
compose_file = (settings or {}).get("compose_file")
|
|
363
372
|
|
|
364
|
-
|
|
365
|
-
|
|
373
|
+
if agent == "codex":
|
|
374
|
+
if plan_mode:
|
|
375
|
+
die("-p is only supported with Claude")
|
|
376
|
+
agent_cmd = ["codex"]
|
|
377
|
+
if non_interactive:
|
|
378
|
+
if not prompt:
|
|
379
|
+
die("-nit requires a prompt")
|
|
380
|
+
agent_cmd += ["exec", "--dangerously-bypass-approvals-and-sandbox"]
|
|
381
|
+
else:
|
|
382
|
+
agent_cmd += ["--dangerously-bypass-approvals-and-sandbox"]
|
|
383
|
+
if model:
|
|
384
|
+
agent_cmd += ["--model", model]
|
|
385
|
+
if prompt:
|
|
386
|
+
agent_cmd.append(prompt)
|
|
387
|
+
else:
|
|
388
|
+
common_flags = ["--dangerously-skip-permissions",
|
|
389
|
+
"--model", model or "claude-opus-4-8",
|
|
366
390
|
"--effort", "max",
|
|
367
391
|
"--remote-control",
|
|
368
392
|
"--remote-control-session-name-prefix", clone_dir.name]
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
if non_interactive:
|
|
394
|
+
if not prompt:
|
|
395
|
+
die("-nit requires a prompt")
|
|
396
|
+
mode_flags = ["--output-format", "stream-json",
|
|
397
|
+
"--verbose", "--include-partial-messages"]
|
|
398
|
+
initial_args = ["-p", prompt]
|
|
399
|
+
elif plan_mode:
|
|
400
|
+
mode_flags = ["--permission-mode", "plan"]
|
|
401
|
+
initial_args = [prompt] if prompt else [f"/color {pick_color()}"]
|
|
402
|
+
else:
|
|
403
|
+
mode_flags = ["--permission-mode", "bypassPermissions"]
|
|
404
|
+
initial_args = [prompt] if prompt else [f"/color {pick_color()}"]
|
|
405
|
+
agent_cmd = ["claude"] + common_flags + mode_flags + initial_args
|
|
381
406
|
|
|
382
407
|
if compose_file:
|
|
383
408
|
project = docker_project_name(clone_dir)
|
|
384
409
|
start_docker(clone_dir, compose_file, project)
|
|
385
410
|
try:
|
|
386
411
|
base = docker_compose_base(clone_dir, compose_file, project)
|
|
387
|
-
|
|
388
|
-
|
|
412
|
+
r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env)
|
|
413
|
+
+ ["dev-environment"] + agent_cmd)
|
|
389
414
|
sys.exit(r.returncode)
|
|
390
415
|
finally:
|
|
391
416
|
stop_docker(clone_dir, compose_file, project)
|
|
392
417
|
else:
|
|
393
|
-
|
|
394
|
-
if not
|
|
395
|
-
die("'
|
|
418
|
+
agent_bin = shutil.which(agent)
|
|
419
|
+
if not agent_bin:
|
|
420
|
+
die(f"'{agent}' not found in PATH")
|
|
396
421
|
os.environ.update(extra_env)
|
|
397
|
-
os.execv(
|
|
422
|
+
os.execv(agent_bin, [agent_bin] + agent_cmd[1:])
|
|
398
423
|
|
|
399
424
|
|
|
400
425
|
SAFE_AGE_SECONDS = 24 * 3600
|
|
@@ -552,7 +577,7 @@ def find_latest_clone(repo: str) -> Path | None:
|
|
|
552
577
|
return best
|
|
553
578
|
|
|
554
579
|
|
|
555
|
-
def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str = "claude
|
|
580
|
+
def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str | None = None, agent: str = "claude") -> None:
|
|
556
581
|
"""Open an existing work folder or remote branch by number."""
|
|
557
582
|
extra_env = extra_env or {}
|
|
558
583
|
clone_dir = PRS_DIR / f"{repo}-{number}"
|
|
@@ -560,13 +585,13 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
|
|
|
560
585
|
# 1. Local folder takes priority
|
|
561
586
|
if clone_dir.exists():
|
|
562
587
|
print(f"luv: opening existing folder {clone_dir.name}")
|
|
563
|
-
ensure_pr_rules()
|
|
588
|
+
ensure_pr_rules(agent)
|
|
564
589
|
if nav_mode:
|
|
565
590
|
navigate(clone_dir, extra_env=extra_env)
|
|
566
591
|
elif resume_mode:
|
|
567
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
592
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
568
593
|
else:
|
|
569
|
-
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
|
|
594
|
+
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
570
595
|
return # unreachable
|
|
571
596
|
|
|
572
597
|
# 2. Check remote branch luv-{number}
|
|
@@ -587,29 +612,29 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
|
|
|
587
612
|
die(f"git checkout {branch} failed (exit {r.returncode})")
|
|
588
613
|
|
|
589
614
|
print(f"luv: ready — {clone_dir.name}, branch {branch}")
|
|
590
|
-
ensure_pr_rules()
|
|
615
|
+
ensure_pr_rules(agent)
|
|
591
616
|
if nav_mode:
|
|
592
617
|
navigate(clone_dir, extra_env=extra_env)
|
|
593
618
|
elif resume_mode:
|
|
594
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
619
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
595
620
|
else:
|
|
596
|
-
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
|
|
621
|
+
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
597
622
|
|
|
598
623
|
|
|
599
|
-
def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str = "claude
|
|
624
|
+
def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str | None = None, agent: str = "claude") -> None:
|
|
600
625
|
"""Open any GitHub PR by org/repo/number, cloning if needed."""
|
|
601
626
|
extra_env = extra_env or {}
|
|
602
627
|
clone_dir = PRS_DIR / f"{repo}-{number}"
|
|
603
628
|
|
|
604
629
|
if clone_dir.exists():
|
|
605
630
|
print(f"luv: opening existing folder {clone_dir.name}")
|
|
606
|
-
ensure_pr_rules()
|
|
631
|
+
ensure_pr_rules(agent)
|
|
607
632
|
if nav_mode:
|
|
608
633
|
navigate(clone_dir, extra_env=extra_env)
|
|
609
634
|
elif resume_mode:
|
|
610
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
635
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
611
636
|
else:
|
|
612
|
-
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
|
|
637
|
+
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
613
638
|
return # unreachable
|
|
614
639
|
|
|
615
640
|
# Resolve the actual branch name via GitHub API
|
|
@@ -630,13 +655,13 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
|
|
|
630
655
|
die(f"git checkout {branch} failed (exit {r.returncode})")
|
|
631
656
|
|
|
632
657
|
print(f"luv: ready — {clone_dir.name}, branch {branch}")
|
|
633
|
-
ensure_pr_rules()
|
|
658
|
+
ensure_pr_rules(agent)
|
|
634
659
|
if nav_mode:
|
|
635
660
|
navigate(clone_dir, extra_env=extra_env)
|
|
636
661
|
elif resume_mode:
|
|
637
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
662
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
638
663
|
else:
|
|
639
|
-
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
|
|
664
|
+
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
640
665
|
|
|
641
666
|
|
|
642
667
|
def main() -> None:
|
|
@@ -650,8 +675,14 @@ def main() -> None:
|
|
|
650
675
|
safe = "--safe" in args
|
|
651
676
|
env_mode = "-e" in args
|
|
652
677
|
|
|
678
|
+
if "--claude" in args and "--codex" in args:
|
|
679
|
+
die("--claude and --codex are mutually exclusive")
|
|
680
|
+
agent = "codex" if "--codex" in args else "claude"
|
|
681
|
+
if agent == "codex" and plan_mode:
|
|
682
|
+
die("-p is only supported with Claude")
|
|
683
|
+
|
|
653
684
|
# -m takes a value, so extract it before the boolean-flag strip below
|
|
654
|
-
model =
|
|
685
|
+
model = None
|
|
655
686
|
if args.count("-m") > 1:
|
|
656
687
|
die("-m may only be provided once")
|
|
657
688
|
if "-m" in args:
|
|
@@ -663,7 +694,20 @@ def main() -> None:
|
|
|
663
694
|
die("-m requires a model name")
|
|
664
695
|
args = args[:idx] + args[idx + 2:]
|
|
665
696
|
|
|
666
|
-
|
|
697
|
+
# -b takes a value (base branch); extract before the boolean-flag strip below.
|
|
698
|
+
base = None
|
|
699
|
+
if args.count("-b") > 1:
|
|
700
|
+
die("-b may only be provided once")
|
|
701
|
+
if "-b" in args:
|
|
702
|
+
idx = args.index("-b")
|
|
703
|
+
if idx + 1 >= len(args):
|
|
704
|
+
die("-b requires a branch name")
|
|
705
|
+
base = args[idx + 1].strip()
|
|
706
|
+
if not base or base.startswith("-"):
|
|
707
|
+
die("-b requires a branch name")
|
|
708
|
+
args = args[:idx] + args[idx + 2:]
|
|
709
|
+
|
|
710
|
+
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe", "--claude", "--codex")]
|
|
667
711
|
extra_env = collect_luv_env() if env_mode else {}
|
|
668
712
|
|
|
669
713
|
if not args or args[0] in ("-h", "--help"):
|
|
@@ -671,11 +715,14 @@ def main() -> None:
|
|
|
671
715
|
Usage: luv [flags] <command>
|
|
672
716
|
|
|
673
717
|
Flags:
|
|
674
|
-
|
|
675
|
-
|
|
718
|
+
--claude launch Claude Code (default)
|
|
719
|
+
--codex launch Codex in YOLO mode (no approvals or sandbox)
|
|
720
|
+
-n navigate: open a shell instead of launching an agent
|
|
721
|
+
-r resume: resume the selected agent's last session
|
|
676
722
|
-p launch Claude in plan permission mode (default: bypassPermissions)
|
|
677
|
-
-nit non-interactive: run
|
|
678
|
-
-m MODEL
|
|
723
|
+
-nit non-interactive: run the selected agent and exit (no REPL)
|
|
724
|
+
-m MODEL model to use (Claude default: claude-opus-4-8; Codex: CLI default)
|
|
725
|
+
-b BRANCH base a new workspace off BRANCH (clone + branch from it; recorded in git config luv.base)
|
|
679
726
|
-e env: pass LUV_* environment variables (with prefix stripped) into the session
|
|
680
727
|
-f, --force (with --clean) skip safety checks and delete all work folders
|
|
681
728
|
--safe (with --clean -f) only delete folders older than 24h
|
|
@@ -683,11 +730,12 @@ Flags:
|
|
|
683
730
|
Commands:
|
|
684
731
|
luv --init configure default GitHub org
|
|
685
732
|
luv [org/]<repo> [prompt...] create a new PR workspace
|
|
733
|
+
luv [org/]<repo> -b <branch> [prompt] create a workspace based off <branch>
|
|
686
734
|
luv [org/]<repo> <number> [prompt] reopen an existing work folder by number
|
|
687
735
|
luv -l <PR URL> [prompt] open any GitHub PR by URL
|
|
688
736
|
luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number
|
|
689
737
|
luv [org/]<repo> -n open shell in latest local clone
|
|
690
|
-
luv [org/]<repo> -r resume
|
|
738
|
+
luv [org/]<repo> -r resume the selected agent in latest local clone
|
|
691
739
|
luv --clean [-f] [--safe] delete fully-pushed work folders
|
|
692
740
|
|
|
693
741
|
Org resolution:
|
|
@@ -696,13 +744,26 @@ Org resolution:
|
|
|
696
744
|
|
|
697
745
|
Docker:
|
|
698
746
|
If the repo contains .luv/settings.json with a "compose_file" key,
|
|
699
|
-
luv starts a Docker Compose environment and runs
|
|
747
|
+
luv starts a Docker Compose environment and runs the selected agent inside the
|
|
700
748
|
"dev-environment" service. Torn down automatically on exit.""")
|
|
701
749
|
sys.exit(0)
|
|
702
750
|
|
|
703
751
|
if safe and (args[0] != "--clean" or not force):
|
|
704
752
|
die("--safe only works with --clean -f")
|
|
705
753
|
|
|
754
|
+
# -b only makes sense when creating a NEW workspace; reject it on every path
|
|
755
|
+
# that early-returns without cloning+branching from scratch.
|
|
756
|
+
if base is not None:
|
|
757
|
+
is_reopen_by_number = len(args) > 1 and args[1].isdigit()
|
|
758
|
+
opens_latest_local = (nav_mode or resume_mode) and len(args) == 1
|
|
759
|
+
if (args[0] in ("--clean", "--init", "-l")
|
|
760
|
+
or "-pr" in args
|
|
761
|
+
or is_reopen_by_number
|
|
762
|
+
or opens_latest_local):
|
|
763
|
+
die("-b only applies when creating a new workspace "
|
|
764
|
+
"(luv [org/]<repo> [prompt...]); it cannot be combined with "
|
|
765
|
+
"--clean, --init, -l, -pr, reopen-by-number, or bare -n/-r")
|
|
766
|
+
|
|
706
767
|
if args[0] == "--clean":
|
|
707
768
|
cmd_clean(force=force, safe=safe)
|
|
708
769
|
return
|
|
@@ -721,7 +782,7 @@ Docker:
|
|
|
721
782
|
die(f"cannot parse PR URL: {url}")
|
|
722
783
|
org, repo, number = m.group(1), m.group(2), int(m.group(3))
|
|
723
784
|
prompt = " ".join(args[2:]) or None
|
|
724
|
-
open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
|
|
785
|
+
open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
725
786
|
return
|
|
726
787
|
|
|
727
788
|
raw = args[0].rstrip("/")
|
|
@@ -741,14 +802,14 @@ Docker:
|
|
|
741
802
|
die(f"expected a PR number after -pr, got '{args[idx + 1]}'")
|
|
742
803
|
prompt_parts = [a for i, a in enumerate(args) if i not in (0, idx, idx + 1)]
|
|
743
804
|
prompt = " ".join(prompt_parts) or None
|
|
744
|
-
open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
|
|
805
|
+
open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
745
806
|
return
|
|
746
807
|
|
|
747
808
|
# Detect optional numeric second argument
|
|
748
809
|
if len(args) > 1 and args[1].isdigit():
|
|
749
810
|
number = int(args[1])
|
|
750
811
|
prompt = " ".join(args[2:]) or None
|
|
751
|
-
open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
|
|
812
|
+
open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
752
813
|
return
|
|
753
814
|
|
|
754
815
|
org = resolve_org(explicit_org)
|
|
@@ -763,7 +824,8 @@ Docker:
|
|
|
763
824
|
if nav_mode:
|
|
764
825
|
navigate(clone_dir, extra_env=extra_env)
|
|
765
826
|
else:
|
|
766
|
-
|
|
827
|
+
ensure_pr_rules(agent)
|
|
828
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
767
829
|
return
|
|
768
830
|
|
|
769
831
|
# 1. Verify repo exists
|
|
@@ -771,6 +833,14 @@ Docker:
|
|
|
771
833
|
if r.returncode != 0:
|
|
772
834
|
die(f"repo '{org}/{repo}' not found or gh auth failed.\n{r.stderr.strip()}")
|
|
773
835
|
|
|
836
|
+
clone_url = f"https://github.com/{org}/{repo}"
|
|
837
|
+
|
|
838
|
+
# 1b. If a base branch was requested, verify it exists before cloning.
|
|
839
|
+
if base is not None:
|
|
840
|
+
r = run(["git", "ls-remote", "--heads", clone_url, base])
|
|
841
|
+
if r.returncode != 0 or f"refs/heads/{base}" not in r.stdout:
|
|
842
|
+
die(f"base branch '{base}' not found on {org}/{repo}")
|
|
843
|
+
|
|
774
844
|
# 2. Get latest issue/PR number (shared counter on GitHub).
|
|
775
845
|
# /issues is documented to include PRs but in practice returns [] for repos
|
|
776
846
|
# with no plain issues, so query both endpoints and take the max.
|
|
@@ -791,23 +861,34 @@ Docker:
|
|
|
791
861
|
candidate += 1
|
|
792
862
|
clone_dir = PRS_DIR / f"{repo}-{candidate}"
|
|
793
863
|
|
|
794
|
-
# 4. Clone
|
|
795
|
-
clone_url
|
|
796
|
-
|
|
797
|
-
|
|
864
|
+
# 4. Clone (off the base branch when -b was given)
|
|
865
|
+
print(f"luv: cloning {clone_url} -> {clone_dir}" + (f" (base {base})" if base else ""))
|
|
866
|
+
clone_cmd = ["git", "clone"]
|
|
867
|
+
if base is not None:
|
|
868
|
+
clone_cmd += ["--branch", base]
|
|
869
|
+
clone_cmd += [clone_url, str(clone_dir)]
|
|
870
|
+
r = subprocess.run(clone_cmd)
|
|
798
871
|
if r.returncode != 0:
|
|
799
872
|
die(f"git clone failed (exit {r.returncode})")
|
|
800
873
|
|
|
801
|
-
# 5. Create branch
|
|
874
|
+
# 5. Create branch off the cloned HEAD (= base when -b was given, else default)
|
|
802
875
|
branch = f"luv-{candidate}"
|
|
803
876
|
print(f"luv: creating branch {branch}")
|
|
804
877
|
r = subprocess.run(["git", "checkout", "-b", branch], cwd=str(clone_dir))
|
|
805
878
|
if r.returncode != 0:
|
|
806
879
|
die(f"git checkout -b failed (exit {r.returncode})")
|
|
807
880
|
|
|
881
|
+
# 5b. Record the base so the eventual PR can target it (local .git/config only).
|
|
882
|
+
if base is not None:
|
|
883
|
+
r = run(["git", "config", "luv.base", base], cwd=str(clone_dir))
|
|
884
|
+
if r.returncode != 0:
|
|
885
|
+
print(f"luv: warning: could not record base branch ({r.stderr.strip()})",
|
|
886
|
+
file=sys.stderr)
|
|
887
|
+
|
|
808
888
|
# 6. Ensure PR rules in ~/.claude/CLAUDE.md and bypass-permissions default
|
|
809
|
-
ensure_pr_rules()
|
|
810
|
-
|
|
889
|
+
ensure_pr_rules(agent)
|
|
890
|
+
if agent == "claude":
|
|
891
|
+
ensure_default_permission_mode()
|
|
811
892
|
|
|
812
893
|
print(f"luv: ready — {clone_dir.name}, branch {branch}")
|
|
813
894
|
|
|
@@ -815,6 +896,6 @@ Docker:
|
|
|
815
896
|
if nav_mode:
|
|
816
897
|
navigate(clone_dir, extra_env=extra_env)
|
|
817
898
|
elif resume_mode:
|
|
818
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
899
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
819
900
|
else:
|
|
820
|
-
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
|
|
901
|
+
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.0.
|
|
8
|
-
description = "Launch Claude Code agents on GitHub repos with isolated workspaces and optional Docker dev environments"
|
|
7
|
+
version = "0.0.24"
|
|
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"
|
|
11
11
|
dependencies = []
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
keywords = ["claude", "cli", "github", "ai", "agent", "docker"]
|
|
13
|
+
keywords = ["claude", "codex", "cli", "github", "ai", "agent", "docker"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
16
16
|
"Environment :: Console",
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import luv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentLaunchTests(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
13
|
+
self.clone_dir = Path(self.tempdir.name)
|
|
14
|
+
|
|
15
|
+
def tearDown(self):
|
|
16
|
+
self.tempdir.cleanup()
|
|
17
|
+
|
|
18
|
+
def _exec_args(self, func, *args, **kwargs):
|
|
19
|
+
with (patch.object(luv, "trust_project"),
|
|
20
|
+
patch.object(luv, "load_luv_settings", return_value=None),
|
|
21
|
+
patch.object(luv.shutil, "which", side_effect=lambda name: f"/bin/{name}"),
|
|
22
|
+
patch.object(luv.os, "chdir"),
|
|
23
|
+
patch.object(luv.os, "execv") as execv):
|
|
24
|
+
func(*args, **kwargs)
|
|
25
|
+
return execv.call_args.args
|
|
26
|
+
|
|
27
|
+
def test_codex_launch_uses_yolo_mode(self):
|
|
28
|
+
executable, argv = self._exec_args(
|
|
29
|
+
luv.launch, self.clone_dir, "fix it", agent="codex")
|
|
30
|
+
|
|
31
|
+
self.assertEqual(executable, "/bin/codex")
|
|
32
|
+
self.assertEqual(argv, [
|
|
33
|
+
"/bin/codex",
|
|
34
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
35
|
+
"fix it",
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
def test_codex_non_interactive_uses_exec(self):
|
|
39
|
+
_, argv = self._exec_args(
|
|
40
|
+
luv.launch, self.clone_dir, "fix it", agent="codex",
|
|
41
|
+
non_interactive=True, model="gpt-5")
|
|
42
|
+
|
|
43
|
+
self.assertEqual(argv, [
|
|
44
|
+
"/bin/codex", "exec",
|
|
45
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
46
|
+
"--model", "gpt-5", "fix it",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
def test_codex_resume_uses_last_session_in_yolo_mode(self):
|
|
50
|
+
_, argv = self._exec_args(
|
|
51
|
+
luv.resume, self.clone_dir, agent="codex")
|
|
52
|
+
|
|
53
|
+
self.assertEqual(argv, [
|
|
54
|
+
"/bin/codex", "resume", "--last",
|
|
55
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
def test_claude_remains_default(self):
|
|
59
|
+
_, argv = self._exec_args(luv.launch, self.clone_dir, None)
|
|
60
|
+
|
|
61
|
+
self.assertEqual(argv[0], "/bin/claude")
|
|
62
|
+
self.assertIn("claude-opus-4-8", argv)
|
|
63
|
+
self.assertIn("--dangerously-skip-permissions", argv)
|
|
64
|
+
|
|
65
|
+
def test_main_routes_codex_to_existing_workspace(self):
|
|
66
|
+
with (patch.object(sys, "argv", ["luv", "--codex", "org/repo", "7"]),
|
|
67
|
+
patch.object(luv, "open_existing") as open_existing):
|
|
68
|
+
luv.main()
|
|
69
|
+
|
|
70
|
+
self.assertEqual(open_existing.call_args.kwargs["agent"], "codex")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|