luv-cli 0.0.16__tar.gz → 0.0.18__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.16
3
+ Version: 0.0.18
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
@@ -87,6 +87,7 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
87
87
  | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
88
88
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
89
89
  | `luv --clean -f` | Force delete all workspaces |
90
+ | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
90
91
 
91
92
  ### Flags
92
93
 
@@ -96,8 +97,10 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
96
97
  | `-r` | Resume: resume the last Claude session |
97
98
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
98
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 |
99
101
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
100
102
  | `-f`, `--force` | Skip safety checks (with `--clean`) |
103
+ | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
101
104
 
102
105
  ## Docker dev environments
103
106
 
@@ -157,7 +160,7 @@ Docker mode works with all flags: `-n` opens a bash shell in the container, `-r`
157
160
  - No unpushed commits
158
161
  - If the remote branch is gone, verifies the PR was merged and local HEAD matches
159
162
 
160
- Use `luv --clean -f` to skip all safety checks and delete everything.
163
+ Use `luv --clean -f` to skip all safety checks and delete everything. Add `--safe` (i.e. `luv --clean --safe -f`) to restrict force-delete to workspaces whose folder mtime is older than 24 hours, leaving recently-touched workspaces alone.
161
164
 
162
165
  ## Configuration
163
166
 
@@ -64,6 +64,7 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
64
64
  | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
65
65
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
66
66
  | `luv --clean -f` | Force delete all workspaces |
67
+ | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
67
68
 
68
69
  ### Flags
69
70
 
@@ -73,8 +74,10 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
73
74
  | `-r` | Resume: resume the last Claude session |
74
75
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
75
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 |
76
78
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
77
79
  | `-f`, `--force` | Skip safety checks (with `--clean`) |
80
+ | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
78
81
 
79
82
  ## Docker dev environments
80
83
 
@@ -134,7 +137,7 @@ Docker mode works with all flags: `-n` opens a bash shell in the container, `-r`
134
137
  - No unpushed commits
135
138
  - If the remote branch is gone, verifies the PR was merged and local HEAD matches
136
139
 
137
- Use `luv --clean -f` to skip all safety checks and delete everything.
140
+ Use `luv --clean -f` to skip all safety checks and delete everything. Add `--safe` (i.e. `luv --clean --safe -f`) to restrict force-delete to workspaces whose folder mtime is older than 24 hours, leaving recently-touched workspaces alone.
138
141
 
139
142
  ## Configuration
140
143
 
@@ -6,6 +6,7 @@ import shutil
6
6
  import subprocess
7
7
  import sys
8
8
  import tempfile
9
+ import time
9
10
  from pathlib import Path
10
11
 
11
12
  LUV_DIR = Path.home() / ".luv"
@@ -315,8 +316,10 @@ def navigate(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
315
316
  os.execv(shell, [shell])
316
317
 
317
318
 
318
- def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
319
+ def resume(clone_dir: Path, extra_env: dict[str, str] | None = None,
320
+ model: str = "claude-opus-4-7") -> None:
319
321
  """Trust, chdir, and exec claude --resume — replacing this process."""
322
+ extra_env = extra_env or {}
320
323
  trust_project(clone_dir)
321
324
  os.chdir(str(clone_dir))
322
325
  settings = load_luv_settings(clone_dir)
@@ -329,7 +332,7 @@ def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
329
332
  base = docker_compose_base(clone_dir, compose_file, project)
330
333
  r = subprocess.run(base + ["exec", "-it"] + docker_env_flags(extra_env) + ["dev-environment",
331
334
  "claude", "--dangerously-skip-permissions",
332
- "--model", "claude-opus-4-7",
335
+ "--model", model,
333
336
  "--effort", "max", "--resume",
334
337
  "--remote-control",
335
338
  "--remote-control-session-name-prefix", clone_dir.name])
@@ -342,21 +345,23 @@ def resume(clone_dir: Path, extra_env: dict[str, str] = {}) -> None:
342
345
  die("'claude' not found in PATH")
343
346
  os.environ.update(extra_env)
344
347
  os.execv(claude_bin, [claude_bin, "--dangerously-skip-permissions",
345
- "--model", "claude-opus-4-7", "--effort", "max", "--resume",
348
+ "--model", model, "--effort", "max", "--resume",
346
349
  "--remote-control",
347
350
  "--remote-control-session-name-prefix", clone_dir.name])
348
351
 
349
352
 
350
353
  def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
351
- non_interactive: bool = False, extra_env: dict[str, str] = {}) -> None:
354
+ non_interactive: bool = False, extra_env: dict[str, str] | None = None,
355
+ model: str = "claude-opus-4-7") -> None:
352
356
  """Trust, resolve claude, chdir, and exec — replacing this process."""
357
+ extra_env = extra_env or {}
353
358
  trust_project(clone_dir)
354
359
  os.chdir(str(clone_dir))
355
360
  settings = load_luv_settings(clone_dir)
356
361
  compose_file = (settings or {}).get("compose_file")
357
362
 
358
363
  common_flags = ["--dangerously-skip-permissions",
359
- "--model", "claude-opus-4-7",
364
+ "--model", model,
360
365
  "--effort", "max",
361
366
  "--remote-control",
362
367
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -391,7 +396,10 @@ def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
391
396
  os.execv(claude_bin, [claude_bin] + common_flags + mode_flags + initial_args)
392
397
 
393
398
 
394
- def cmd_clean(force: bool = False) -> None:
399
+ SAFE_AGE_SECONDS = 24 * 3600
400
+
401
+
402
+ def cmd_clean(force: bool = False, safe: bool = False) -> None:
395
403
  """Scan ~/prs/ and delete fully-pushed, clean work folders."""
396
404
  if not PRS_DIR.exists():
397
405
  print("luv: nothing to clean (~/prs/ does not exist)")
@@ -399,6 +407,7 @@ def cmd_clean(force: bool = False) -> None:
399
407
 
400
408
  cleaned: list[str] = []
401
409
  skipped: list[tuple[str, str]] = []
410
+ now = time.time()
402
411
 
403
412
  for entry in sorted(PRS_DIR.iterdir()):
404
413
  if not entry.is_dir():
@@ -409,6 +418,9 @@ def cmd_clean(force: bool = False) -> None:
409
418
  continue # doesn't match {repo}-{number} — skip silently
410
419
 
411
420
  if force:
421
+ if safe and (now - entry.stat().st_mtime) < SAFE_AGE_SECONDS:
422
+ skipped.append((entry.name, "younger than 24h (--safe)"))
423
+ continue
412
424
  shutil.rmtree(entry)
413
425
  cleaned.append(entry.name)
414
426
  continue
@@ -496,8 +508,9 @@ def find_latest_clone(repo: str) -> Path | None:
496
508
  return best
497
509
 
498
510
 
499
- 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:
511
+ 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:
500
512
  """Open an existing work folder or remote branch by number."""
513
+ extra_env = extra_env or {}
501
514
  clone_dir = PRS_DIR / f"{repo}-{number}"
502
515
 
503
516
  # 1. Local folder takes priority
@@ -507,9 +520,9 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
507
520
  if nav_mode:
508
521
  navigate(clone_dir, extra_env=extra_env)
509
522
  elif resume_mode:
510
- resume(clone_dir, extra_env=extra_env)
523
+ resume(clone_dir, extra_env=extra_env, model=model)
511
524
  else:
512
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
525
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
513
526
  return # unreachable
514
527
 
515
528
  # 2. Check remote branch luv-{number}
@@ -534,13 +547,14 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
534
547
  if nav_mode:
535
548
  navigate(clone_dir, extra_env=extra_env)
536
549
  elif resume_mode:
537
- resume(clone_dir, extra_env=extra_env)
550
+ resume(clone_dir, extra_env=extra_env, model=model)
538
551
  else:
539
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
552
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
540
553
 
541
554
 
542
- 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:
555
+ 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:
543
556
  """Open any GitHub PR by org/repo/number, cloning if needed."""
557
+ extra_env = extra_env or {}
544
558
  clone_dir = PRS_DIR / f"{repo}-{number}"
545
559
 
546
560
  if clone_dir.exists():
@@ -549,9 +563,9 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
549
563
  if nav_mode:
550
564
  navigate(clone_dir, extra_env=extra_env)
551
565
  elif resume_mode:
552
- resume(clone_dir, extra_env=extra_env)
566
+ resume(clone_dir, extra_env=extra_env, model=model)
553
567
  else:
554
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
568
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
555
569
  return # unreachable
556
570
 
557
571
  # Resolve the actual branch name via GitHub API
@@ -576,9 +590,9 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
576
590
  if nav_mode:
577
591
  navigate(clone_dir, extra_env=extra_env)
578
592
  elif resume_mode:
579
- resume(clone_dir, extra_env=extra_env)
593
+ resume(clone_dir, extra_env=extra_env, model=model)
580
594
  else:
581
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
595
+ launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model)
582
596
 
583
597
 
584
598
  def main() -> None:
@@ -589,8 +603,23 @@ def main() -> None:
589
603
  plan_mode = "-p" in args
590
604
  non_interactive = "-nit" in args
591
605
  force = "-f" in args or "--force" in args
606
+ safe = "--safe" in args
592
607
  env_mode = "-e" in args
593
- args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit")]
608
+
609
+ # -m takes a value, so extract it before the boolean-flag strip below
610
+ model = "claude-opus-4-7"
611
+ if args.count("-m") > 1:
612
+ die("-m may only be provided once")
613
+ if "-m" in args:
614
+ idx = args.index("-m")
615
+ if idx + 1 >= len(args):
616
+ die("-m requires a model name")
617
+ model = args[idx + 1].strip()
618
+ if not model or model.startswith("-"):
619
+ die("-m requires a model name")
620
+ args = args[:idx] + args[idx + 2:]
621
+
622
+ args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe")]
594
623
  extra_env = collect_luv_env() if env_mode else {}
595
624
 
596
625
  if not args or args[0] in ("-h", "--help"):
@@ -602,8 +631,10 @@ Flags:
602
631
  -r resume: resume the last Claude session in the work folder
603
632
  -p launch Claude in plan permission mode (default: bypassPermissions)
604
633
  -nit non-interactive: run claude -p <prompt> and exit (no REPL)
634
+ -m MODEL claude model to use (default: claude-opus-4-7)
605
635
  -e env: pass LUV_* environment variables (with prefix stripped) into the session
606
636
  -f, --force (with --clean) skip safety checks and delete all work folders
637
+ --safe (with --clean -f) only delete folders older than 24h
607
638
 
608
639
  Commands:
609
640
  luv --init configure default GitHub org
@@ -613,7 +644,7 @@ Commands:
613
644
  luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number
614
645
  luv [org/]<repo> -n open shell in latest local clone
615
646
  luv [org/]<repo> -r resume Claude in latest local clone
616
- luv --clean [-f] delete fully-pushed work folders
647
+ luv --clean [-f] [--safe] delete fully-pushed work folders
617
648
 
618
649
  Org resolution:
619
650
  Explicit org/repo overrides the default. Run 'luv --init' to set a default.
@@ -625,8 +656,11 @@ Docker:
625
656
  "dev-environment" service. Torn down automatically on exit.""")
626
657
  sys.exit(0)
627
658
 
659
+ if safe and (args[0] != "--clean" or not force):
660
+ die("--safe only works with --clean -f")
661
+
628
662
  if args[0] == "--clean":
629
- cmd_clean(force=force)
663
+ cmd_clean(force=force, safe=safe)
630
664
  return
631
665
 
632
666
  if args[0] == "--init":
@@ -643,7 +677,7 @@ Docker:
643
677
  die(f"cannot parse PR URL: {url}")
644
678
  org, repo, number = m.group(1), m.group(2), int(m.group(3))
645
679
  prompt = " ".join(args[2:]) or None
646
- open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
680
+ open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
647
681
  return
648
682
 
649
683
  raw = args[0].rstrip("/")
@@ -663,14 +697,14 @@ Docker:
663
697
  die(f"expected a PR number after -pr, got '{args[idx + 1]}'")
664
698
  prompt_parts = [a for i, a in enumerate(args) if i not in (0, idx, idx + 1)]
665
699
  prompt = " ".join(prompt_parts) or None
666
- open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
700
+ open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
667
701
  return
668
702
 
669
703
  # Detect optional numeric second argument
670
704
  if len(args) > 1 and args[1].isdigit():
671
705
  number = int(args[1])
672
706
  prompt = " ".join(args[2:]) or None
673
- open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env)
707
+ open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model)
674
708
  return
675
709
 
676
710
  org = resolve_org(explicit_org)
@@ -685,7 +719,7 @@ Docker:
685
719
  if nav_mode:
686
720
  navigate(clone_dir, extra_env=extra_env)
687
721
  else:
688
- resume(clone_dir, extra_env=extra_env)
722
+ resume(clone_dir, extra_env=extra_env, model=model)
689
723
  return
690
724
 
691
725
  # 1. Verify repo exists
@@ -737,6 +771,6 @@ Docker:
737
771
  if nav_mode:
738
772
  navigate(clone_dir, extra_env=extra_env)
739
773
  elif resume_mode:
740
- resume(clone_dir, extra_env=extra_env)
774
+ resume(clone_dir, extra_env=extra_env, model=model)
741
775
  else:
742
- launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env)
776
+ 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.16"
7
+ version = "0.0.18"
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