luv-cli 0.0.17__tar.gz → 0.0.19__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.0.17
3
+ Version: 0.0.19
4
4
  Summary: Launch Claude Code 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
@@ -97,6 +97,7 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
97
97
  | `-r` | Resume: resume the last Claude session |
98
98
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
99
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-7`); passed through to `claude --model`, so aliases like `opus`/`sonnet`/`haiku` work |
100
101
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
101
102
  | `-f`, `--force` | Skip safety checks (with `--clean`) |
102
103
  | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
@@ -74,6 +74,7 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
74
74
  | `-r` | Resume: resume the last Claude session |
75
75
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
76
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-7`); passed through to `claude --model`, so aliases like `opus`/`sonnet`/`haiku` work |
77
78
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
78
79
  | `-f`, `--force` | Skip safety checks (with `--clean`) |
79
80
  | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
@@ -3,6 +3,7 @@ import os
3
3
  import random
4
4
  import re
5
5
  import shutil
6
+ import stat
6
7
  import subprocess
7
8
  import sys
8
9
  import tempfile
@@ -316,8 +317,10 @@ def navigate(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
316
317
  os.execv(shell, [shell])
317
318
 
318
319
 
319
- def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
320
+ def resume(clone_dir: Path, extra_env: dict[str, str] | None = None,
321
+ model: str = "claude-opus-4-7") -> None:
320
322
  """Trust, chdir, and exec claude --resume — replacing this process."""
323
+ extra_env = extra_env or {}
321
324
  trust_project(clone_dir)
322
325
  os.chdir(str(clone_dir))
323
326
  settings = load_luv_settings(clone_dir)
@@ -330,7 +333,7 @@ def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
330
333
  base = docker_compose_base(clone_dir, compose_file, project)
331
334
  r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env) + ["dev-environment",
332
335
  "claude", "--dangerously-skip-permissions",
333
- "--model", "claude-opus-4-7",
336
+ "--model", model,
334
337
  "--effort", "max", "--resume",
335
338
  "--remote-control",
336
339
  "--remote-control-session-name-prefix", clone_dir.name])
@@ -343,21 +346,23 @@ def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
343
346
  die("'claude' not found in PATH")
344
347
  os.environ.update(extra_env)
345
348
  os.execv(claude_bin, [claude_bin, "--dangerously-skip-permissions",
346
- "--model", "claude-opus-4-7", "--effort", "max", "--resume",
349
+ "--model", model, "--effort", "max", "--resume",
347
350
  "--remote-control",
348
351
  "--remote-control-session-name-prefix", clone_dir.name])
349
352
 
350
353
 
351
354
  def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
352
- non_interactive: bool = False, extra_env: dict[str, str] = {}) -> None:
355
+ non_interactive: bool = False, extra_env: dict[str, str] | None = None,
356
+ model: str = "claude-opus-4-7") -> None:
353
357
  """Trust, resolve claude, chdir, and exec — replacing this process."""
358
+ extra_env = extra_env or {}
354
359
  trust_project(clone_dir)
355
360
  os.chdir(str(clone_dir))
356
361
  settings = load_luv_settings(clone_dir)
357
362
  compose_file = (settings or {}).get("compose_file")
358
363
 
359
364
  common_flags = ["--dangerously-skip-permissions",
360
- "--model", "claude-opus-4-7",
365
+ "--model", model,
361
366
  "--effort", "max",
362
367
  "--remote-control",
363
368
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -395,6 +400,23 @@ def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
395
400
  SAFE_AGE_SECONDS = 24 * 3600
396
401
 
397
402
 
403
+ def _on_rm_error(func, path, _exc):
404
+ """rmtree handler: make `path` (and its parent dir) writable, then retry."""
405
+ parent = os.path.dirname(path)
406
+ try:
407
+ os.chmod(parent, os.stat(parent).st_mode | stat.S_IWUSR | stat.S_IXUSR)
408
+ except OSError:
409
+ pass
410
+ os.chmod(path, stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR)
411
+ func(path)
412
+
413
+
414
+ def _force_rmtree(path: Path) -> None:
415
+ """rmtree that chmods read-only files (e.g. uv's CACHEDIR.TAG) instead of crashing."""
416
+ kwargs = {"onexc": _on_rm_error} if sys.version_info >= (3, 12) else {"onerror": _on_rm_error}
417
+ shutil.rmtree(path, **kwargs)
418
+
419
+
398
420
  def cmd_clean(force: bool = False, safe: bool = False) -> None:
399
421
  """Scan ~/prs/ and delete fully-pushed, clean work folders."""
400
422
  if not PRS_DIR.exists():
@@ -417,7 +439,7 @@ def cmd_clean(force: bool = False, safe: bool = False) -> None:
417
439
  if safe and (now - entry.stat().st_mtime) < SAFE_AGE_SECONDS:
418
440
  skipped.append((entry.name, "younger than 24h (--safe)"))
419
441
  continue
420
- shutil.rmtree(entry)
442
+ _force_rmtree(entry)
421
443
  cleaned.append(entry.name)
422
444
  continue
423
445
 
@@ -460,7 +482,7 @@ def cmd_clean(force: bool = False, safe: bool = False) -> None:
460
482
  if local_sha != pr_head_sha:
461
483
  skipped.append((entry.name, "local HEAD differs from merged PR head"))
462
484
  continue
463
- shutil.rmtree(entry)
485
+ _force_rmtree(entry)
464
486
  cleaned.append(entry.name)
465
487
  continue
466
488
 
@@ -470,7 +492,7 @@ def cmd_clean(force: bool = False, safe: bool = False) -> None:
470
492
  skipped.append((entry.name, "unpushed commits"))
471
493
  continue
472
494
 
473
- shutil.rmtree(entry)
495
+ _force_rmtree(entry)
474
496
  cleaned.append(entry.name)
475
497
 
476
498
  if skipped:
@@ -504,8 +526,9 @@ def find_latest_clone(repo: str) -> Path | None:
504
526
  return best
505
527
 
506
528
 
507
- 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:
529
+ 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-7") -> None:
508
530
  """Open an existing work folder or remote branch by number."""
531
+ extra_env = extra_env or {}
509
532
  clone_dir = PRS_DIR / f"{repo}-{number}"
510
533
 
511
534
  # 1. Local folder takes priority
@@ -515,9 +538,9 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
515
538
  if nav_mode:
516
539
  navigate(clone_dir, extra_env=extra_env)
517
540
  elif resume_mode:
518
- resume(clone_dir, extra_env=extra_env)
541
+ resume(clone_dir, extra_env=extra_env, model=model)
519
542
  else:
520
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
543
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
521
544
  return # unreachable
522
545
 
523
546
  # 2. Check remote branch luv-{number}
@@ -542,13 +565,14 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
542
565
  if nav_mode:
543
566
  navigate(clone_dir, extra_env=extra_env)
544
567
  elif resume_mode:
545
- resume(clone_dir, extra_env=extra_env)
568
+ resume(clone_dir, extra_env=extra_env, model=model)
546
569
  else:
547
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
570
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
548
571
 
549
572
 
550
- 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:
573
+ 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-7") -> None:
551
574
  """Open any GitHub PR by org/repo/number, cloning if needed."""
575
+ extra_env = extra_env or {}
552
576
  clone_dir = PRS_DIR / f"{repo}-{number}"
553
577
 
554
578
  if clone_dir.exists():
@@ -557,9 +581,9 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
557
581
  if nav_mode:
558
582
  navigate(clone_dir, extra_env=extra_env)
559
583
  elif resume_mode:
560
- resume(clone_dir, extra_env=extra_env)
584
+ resume(clone_dir, extra_env=extra_env, model=model)
561
585
  else:
562
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
586
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
563
587
  return # unreachable
564
588
 
565
589
  # Resolve the actual branch name via GitHub API
@@ -584,9 +608,9 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
584
608
  if nav_mode:
585
609
  navigate(clone_dir, extra_env=extra_env)
586
610
  elif resume_mode:
587
- resume(clone_dir, extra_env=extra_env)
611
+ resume(clone_dir, extra_env=extra_env, model=model)
588
612
  else:
589
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
613
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
590
614
 
591
615
 
592
616
  def main() -> None:
@@ -599,6 +623,20 @@ def main() -> None:
599
623
  force = "-f" in args or "--force" in args
600
624
  safe = "--safe" in args
601
625
  env_mode = "-e" in args
626
+
627
+ # -m takes a value, so extract it before the boolean-flag strip below
628
+ model = "claude-opus-4-7"
629
+ if args.count("-m") > 1:
630
+ die("-m may only be provided once")
631
+ if "-m" in args:
632
+ idx = args.index("-m")
633
+ if idx + 1 >= len(args):
634
+ die("-m requires a model name")
635
+ model = args[idx + 1].strip()
636
+ if not model or model.startswith("-"):
637
+ die("-m requires a model name")
638
+ args = args[:idx] + args[idx + 2:]
639
+
602
640
  args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe")]
603
641
  extra_env = collect_luv_env() if env_mode else {}
604
642
 
@@ -611,6 +649,7 @@ Flags:
611
649
  -r resume: resume the last Claude session in the work folder
612
650
  -p launch Claude in plan permission mode (default: bypassPermissions)
613
651
  -nit non-interactive: run claude -p <prompt> and exit (no REPL)
652
+ -m MODEL claude model to use (default: claude-opus-4-7)
614
653
  -e env: pass LUV_* environment variables (with prefix stripped) into the session
615
654
  -f, --force (with --clean) skip safety checks and delete all work folders
616
655
  --safe (with --clean -f) only delete folders older than 24h
@@ -656,7 +695,7 @@ Docker:
656
695
  die(f"cannot parse PR URL: {url}")
657
696
  org, repo, number = m.group(1), m.group(2), int(m.group(3))
658
697
  prompt = " ".join(args[2:]) or None
659
- open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
698
+ open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
660
699
  return
661
700
 
662
701
  raw = args[0].rstrip("/")
@@ -676,14 +715,14 @@ Docker:
676
715
  die(f"expected a PR number after -pr, got '{args[idx + 1]}'")
677
716
  prompt_parts = [a for i, a in enumerate(args) if i not in (0, idx, idx + 1)]
678
717
  prompt = " ".join(prompt_parts) or None
679
- open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
718
+ open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
680
719
  return
681
720
 
682
721
  # Detect optional numeric second argument
683
722
  if len(args) > 1 and args[1].isdigit():
684
723
  number = int(args[1])
685
724
  prompt = " ".join(args[2:]) or None
686
- open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
725
+ open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
687
726
  return
688
727
 
689
728
  org = resolve_org(explicit_org)
@@ -698,7 +737,7 @@ Docker:
698
737
  if nav_mode:
699
738
  navigate(clone_dir, extra_env=extra_env)
700
739
  else:
701
- resume(clone_dir, extra_env=extra_env)
740
+ resume(clone_dir, extra_env=extra_env, model=model)
702
741
  return
703
742
 
704
743
  # 1. Verify repo exists
@@ -750,6 +789,6 @@ Docker:
750
789
  if nav_mode:
751
790
  navigate(clone_dir, extra_env=extra_env)
752
791
  elif resume_mode:
753
- resume(clone_dir, extra_env=extra_env)
792
+ resume(clone_dir, extra_env=extra_env, model=model)
754
793
  else:
755
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
794
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "luv-cli"
7
- version = "0.0.17"
7
+ version = "0.0.19"
8
8
  description = "Launch Claude Code agents on GitHub repos with isolated workspaces and optional Docker dev environments"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
File without changes
File without changes
File without changes