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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.0.22
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,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. Trusts the project in Claude Code config
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 Claude session |
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 `claude -p <prompt>` and exit (no REPL); streams `stream-json` events to stdout |
100
- | `-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 |
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 [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.
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 Claude inside the container via `docker compose exec`
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 [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,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. Trusts the project in Claude Code config
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 Claude session |
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 `claude -p <prompt>` and exit (no REPL); streams `stream-json` events to stdout |
77
- | `-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 |
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 [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.
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 Claude inside the container via `docker compose exec`
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
- 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:
@@ -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
- args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe")]
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
- -n navigate: open a shell in the work folder instead of launching Claude
675
- -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
676
722
  -p launch Claude in plan permission mode (default: bypassPermissions)
677
- -nit non-interactive: run claude -p <prompt> and exit (no REPL)
678
- -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)
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 Claude in latest local clone
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 Claude inside the
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
- 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)
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 = f"https://github.com/{org}/{repo}"
796
- print(f"luv: cloning {clone_url} -> {clone_dir}")
797
- r = subprocess.run(["git", "clone", clone_url, str(clone_dir)])
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
- ensure_default_permission_mode()
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.22"
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