luv-cli 0.0.23__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.23 → luv_cli-0.0.24}/PKG-INFO +22 -13
- {luv_cli-0.0.23 → luv_cli-0.0.24}/README.md +19 -10
- luv_cli-0.0.24/claude-output.txt +0 -0
- {luv_cli-0.0.23 → luv_cli-0.0.24}/luv/__init__.py +110 -75
- {luv_cli-0.0.23 → 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.23 → luv_cli-0.0.24}/.claude/settings.json +0 -0
- {luv_cli-0.0.23 → luv_cli-0.0.24}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.0.23 → luv_cli-0.0.24}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.0.23 → luv_cli-0.0.24}/.gitignore +0 -0
- {luv_cli-0.0.23 → 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,12 @@ 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
|
+
|
|
51
57
|
# Base a new workspace off a non-default branch
|
|
52
58
|
luv my-repo -b develop "add user authentication"
|
|
53
59
|
|
|
@@ -74,8 +80,8 @@ luv --clean
|
|
|
74
80
|
|
|
75
81
|
1. Clones the repo into `~/prs/{repo}-{number}/`
|
|
76
82
|
2. Creates a new branch `luv-{number}`
|
|
77
|
-
3.
|
|
78
|
-
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
|
|
79
85
|
|
|
80
86
|
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
|
|
81
87
|
|
|
@@ -84,7 +90,8 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
84
90
|
| Command | Description |
|
|
85
91
|
|---------|-------------|
|
|
86
92
|
| `luv --init` | Configure default GitHub org |
|
|
87
|
-
| `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 |
|
|
88
95
|
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
89
96
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
90
97
|
| `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
|
|
@@ -97,11 +104,13 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
97
104
|
|
|
98
105
|
| Flag | Description |
|
|
99
106
|
|------|-------------|
|
|
107
|
+
| `--claude` | Launch Claude Code (default) |
|
|
108
|
+
| `--codex` | Launch Codex with approvals and sandboxing bypassed (YOLO mode) |
|
|
100
109
|
| `-n` | Navigate: open a shell instead of Claude |
|
|
101
|
-
| `-r` | Resume: resume the last
|
|
110
|
+
| `-r` | Resume: resume the selected agent's last session |
|
|
102
111
|
| `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
|
|
103
|
-
| `-nit` | Non-interactive: run
|
|
104
|
-
| `-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 |
|
|
105
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 |
|
|
106
115
|
| `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
|
|
107
116
|
| `-f`, `--force` | Skip safety checks (with `--clean`) |
|
|
@@ -143,7 +152,7 @@ services:
|
|
|
143
152
|
POSTGRES_PASSWORD: dev
|
|
144
153
|
```
|
|
145
154
|
|
|
146
|
-
The `dev-environment` service **must** have
|
|
155
|
+
The `dev-environment` service **must** have the selected agent CLI (`claude` or `codex`) installed in its image.
|
|
147
156
|
|
|
148
157
|
### How Docker mode works
|
|
149
158
|
|
|
@@ -151,7 +160,7 @@ The `dev-environment` service **must** have [Claude Code](https://docs.anthropic
|
|
|
151
160
|
2. Tears down any stale environment from a previous run
|
|
152
161
|
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
|
|
153
162
|
4. Verifies the `dev-environment` service is running
|
|
154
|
-
5. Runs
|
|
163
|
+
5. Runs the selected agent inside the container via `docker compose exec`
|
|
155
164
|
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
|
|
156
165
|
7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
|
|
157
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,12 @@ 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
|
+
|
|
28
34
|
# Base a new workspace off a non-default branch
|
|
29
35
|
luv my-repo -b develop "add user authentication"
|
|
30
36
|
|
|
@@ -51,8 +57,8 @@ luv --clean
|
|
|
51
57
|
|
|
52
58
|
1. Clones the repo into `~/prs/{repo}-{number}/`
|
|
53
59
|
2. Creates a new branch `luv-{number}`
|
|
54
|
-
3.
|
|
55
|
-
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
|
|
56
62
|
|
|
57
63
|
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
|
|
58
64
|
|
|
@@ -61,7 +67,8 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
61
67
|
| Command | Description |
|
|
62
68
|
|---------|-------------|
|
|
63
69
|
| `luv --init` | Configure default GitHub org |
|
|
64
|
-
| `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 |
|
|
65
72
|
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
66
73
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
67
74
|
| `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
|
|
@@ -74,11 +81,13 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
|
|
|
74
81
|
|
|
75
82
|
| Flag | Description |
|
|
76
83
|
|------|-------------|
|
|
84
|
+
| `--claude` | Launch Claude Code (default) |
|
|
85
|
+
| `--codex` | Launch Codex with approvals and sandboxing bypassed (YOLO mode) |
|
|
77
86
|
| `-n` | Navigate: open a shell instead of Claude |
|
|
78
|
-
| `-r` | Resume: resume the last
|
|
87
|
+
| `-r` | Resume: resume the selected agent's last session |
|
|
79
88
|
| `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
|
|
80
|
-
| `-nit` | Non-interactive: run
|
|
81
|
-
| `-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 |
|
|
82
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 |
|
|
83
92
|
| `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
|
|
84
93
|
| `-f`, `--force` | Skip safety checks (with `--clean`) |
|
|
@@ -120,7 +129,7 @@ services:
|
|
|
120
129
|
POSTGRES_PASSWORD: dev
|
|
121
130
|
```
|
|
122
131
|
|
|
123
|
-
The `dev-environment` service **must** have
|
|
132
|
+
The `dev-environment` service **must** have the selected agent CLI (`claude` or `codex`) installed in its image.
|
|
124
133
|
|
|
125
134
|
### How Docker mode works
|
|
126
135
|
|
|
@@ -128,7 +137,7 @@ The `dev-environment` service **must** have [Claude Code](https://docs.anthropic
|
|
|
128
137
|
2. Tears down any stale environment from a previous run
|
|
129
138
|
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
|
|
130
139
|
4. Verifies the `dev-environment` service is running
|
|
131
|
-
5. Runs
|
|
140
|
+
5. Runs the selected agent inside the container via `docker compose exec`
|
|
132
141
|
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
|
|
133
142
|
7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
|
|
134
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:
|
|
@@ -676,7 +707,7 @@ def main() -> None:
|
|
|
676
707
|
die("-b requires a branch name")
|
|
677
708
|
args = args[:idx] + args[idx + 2:]
|
|
678
709
|
|
|
679
|
-
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe")]
|
|
710
|
+
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe", "--claude", "--codex")]
|
|
680
711
|
extra_env = collect_luv_env() if env_mode else {}
|
|
681
712
|
|
|
682
713
|
if not args or args[0] in ("-h", "--help"):
|
|
@@ -684,11 +715,13 @@ def main() -> None:
|
|
|
684
715
|
Usage: luv [flags] <command>
|
|
685
716
|
|
|
686
717
|
Flags:
|
|
687
|
-
|
|
688
|
-
|
|
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
|
|
689
722
|
-p launch Claude in plan permission mode (default: bypassPermissions)
|
|
690
|
-
-nit non-interactive: run
|
|
691
|
-
-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)
|
|
692
725
|
-b BRANCH base a new workspace off BRANCH (clone + branch from it; recorded in git config luv.base)
|
|
693
726
|
-e env: pass LUV_* environment variables (with prefix stripped) into the session
|
|
694
727
|
-f, --force (with --clean) skip safety checks and delete all work folders
|
|
@@ -702,7 +735,7 @@ Commands:
|
|
|
702
735
|
luv -l <PR URL> [prompt] open any GitHub PR by URL
|
|
703
736
|
luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number
|
|
704
737
|
luv [org/]<repo> -n open shell in latest local clone
|
|
705
|
-
luv [org/]<repo> -r resume
|
|
738
|
+
luv [org/]<repo> -r resume the selected agent in latest local clone
|
|
706
739
|
luv --clean [-f] [--safe] delete fully-pushed work folders
|
|
707
740
|
|
|
708
741
|
Org resolution:
|
|
@@ -711,7 +744,7 @@ Org resolution:
|
|
|
711
744
|
|
|
712
745
|
Docker:
|
|
713
746
|
If the repo contains .luv/settings.json with a "compose_file" key,
|
|
714
|
-
luv starts a Docker Compose environment and runs
|
|
747
|
+
luv starts a Docker Compose environment and runs the selected agent inside the
|
|
715
748
|
"dev-environment" service. Torn down automatically on exit.""")
|
|
716
749
|
sys.exit(0)
|
|
717
750
|
|
|
@@ -749,7 +782,7 @@ Docker:
|
|
|
749
782
|
die(f"cannot parse PR URL: {url}")
|
|
750
783
|
org, repo, number = m.group(1), m.group(2), int(m.group(3))
|
|
751
784
|
prompt = " ".join(args[2:]) or None
|
|
752
|
-
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)
|
|
753
786
|
return
|
|
754
787
|
|
|
755
788
|
raw = args[0].rstrip("/")
|
|
@@ -769,14 +802,14 @@ Docker:
|
|
|
769
802
|
die(f"expected a PR number after -pr, got '{args[idx + 1]}'")
|
|
770
803
|
prompt_parts = [a for i, a in enumerate(args) if i not in (0, idx, idx + 1)]
|
|
771
804
|
prompt = " ".join(prompt_parts) or None
|
|
772
|
-
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)
|
|
773
806
|
return
|
|
774
807
|
|
|
775
808
|
# Detect optional numeric second argument
|
|
776
809
|
if len(args) > 1 and args[1].isdigit():
|
|
777
810
|
number = int(args[1])
|
|
778
811
|
prompt = " ".join(args[2:]) or None
|
|
779
|
-
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)
|
|
780
813
|
return
|
|
781
814
|
|
|
782
815
|
org = resolve_org(explicit_org)
|
|
@@ -791,7 +824,8 @@ Docker:
|
|
|
791
824
|
if nav_mode:
|
|
792
825
|
navigate(clone_dir, extra_env=extra_env)
|
|
793
826
|
else:
|
|
794
|
-
|
|
827
|
+
ensure_pr_rules(agent)
|
|
828
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
795
829
|
return
|
|
796
830
|
|
|
797
831
|
# 1. Verify repo exists
|
|
@@ -852,8 +886,9 @@ Docker:
|
|
|
852
886
|
file=sys.stderr)
|
|
853
887
|
|
|
854
888
|
# 6. Ensure PR rules in ~/.claude/CLAUDE.md and bypass-permissions default
|
|
855
|
-
ensure_pr_rules()
|
|
856
|
-
|
|
889
|
+
ensure_pr_rules(agent)
|
|
890
|
+
if agent == "claude":
|
|
891
|
+
ensure_default_permission_mode()
|
|
857
892
|
|
|
858
893
|
print(f"luv: ready — {clone_dir.name}, branch {branch}")
|
|
859
894
|
|
|
@@ -861,6 +896,6 @@ Docker:
|
|
|
861
896
|
if nav_mode:
|
|
862
897
|
navigate(clone_dir, extra_env=extra_env)
|
|
863
898
|
elif resume_mode:
|
|
864
|
-
resume(clone_dir, extra_env=extra_env, model=model)
|
|
899
|
+
resume(clone_dir, extra_env=extra_env, model=model, agent=agent)
|
|
865
900
|
else:
|
|
866
|
-
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
|