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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.0.23
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 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) agents on GitHub repos with isolated workspaces and optional Docker dev environments.
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:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI and [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated.
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. Trusts the project in Claude Code config
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 Claude session |
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 `claude -p <prompt>` and exit (no REPL); streams `stream-json` events to stdout |
104
- | `-m MODEL` | Claude model to use (default: `claude-opus-4-8`); passed through to `claude --model`, so aliases like `opus`/`sonnet`/`haiku` work |
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 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed in its image.
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 Claude inside the container via `docker compose exec`
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 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) agents on GitHub repos with isolated workspaces and optional Docker dev environments.
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:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI and [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated.
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. Trusts the project in Claude Code config
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 Claude session |
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 `claude -p <prompt>` and exit (no REPL); streams `stream-json` events to stdout |
81
- | `-m MODEL` | Claude model to use (default: `claude-opus-4-8`); passed through to `claude --model`, so aliases like `opus`/`sonnet`/`haiku` work |
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 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed in its image.
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 Claude inside the container via `docker compose exec`
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
- claude_dir = Path.home() / ".claude"
152
- claude_md = claude_dir / "CLAUDE.md"
153
- claude_dir.mkdir(parents=True, exist_ok=True)
154
- existing = claude_md.read_text() if claude_md.exists() else ""
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 claude_md.open("a") as f:
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-opus-4-8") -> None:
322
- """Trust, chdir, and exec claude --resume — replacing this process."""
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
- trust_project(clone_dir)
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) + ["dev-environment",
335
- "claude", "--dangerously-skip-permissions",
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
- claude_bin = shutil.which("claude")
345
- if not claude_bin:
346
- die("'claude' not found in PATH")
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(claude_bin, [claude_bin, "--dangerously-skip-permissions",
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-opus-4-8") -> None:
357
- """Trust, resolve claude, chdir, and exec — replacing this process."""
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
- trust_project(clone_dir)
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
- common_flags = ["--dangerously-skip-permissions",
365
- "--model", model,
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
- if non_interactive:
370
- if not prompt:
371
- die("-nit requires a prompt")
372
- mode_flags = ["--output-format", "stream-json",
373
- "--verbose", "--include-partial-messages"]
374
- initial_args = ["-p", prompt]
375
- elif plan_mode:
376
- mode_flags = ["--permission-mode", "plan"]
377
- initial_args = [prompt] if prompt else [f"/color {pick_color()}"]
378
- else:
379
- mode_flags = ["--permission-mode", "bypassPermissions"]
380
- initial_args = [prompt] if prompt else [f"/color {pick_color()}"]
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
- claude_cmd = ["claude"] + common_flags + mode_flags + initial_args
388
- r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env) + ["dev-environment"] + claude_cmd)
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
- claude_bin = shutil.which("claude")
394
- if not claude_bin:
395
- die("'claude' not found in PATH")
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(claude_bin, [claude_bin] + common_flags + mode_flags + initial_args)
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-opus-4-8") -> None:
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-opus-4-8") -> None:
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 = "claude-opus-4-8"
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
- -n navigate: open a shell in the work folder instead of launching Claude
688
- -r resume: resume the last Claude session in the work folder
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 claude -p <prompt> and exit (no REPL)
691
- -m MODEL claude model to use (default: claude-opus-4-8)
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 Claude in latest local clone
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 Claude inside the
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
- resume(clone_dir, extra_env=extra_env, model=model)
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
- ensure_default_permission_mode()
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.23"
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