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.
- {luv_cli-0.0.16 → luv_cli-0.0.18}/PKG-INFO +5 -2
- {luv_cli-0.0.16 → luv_cli-0.0.18}/README.md +4 -1
- {luv_cli-0.0.16 → luv_cli-0.0.18}/luv/__init__.py +59 -25
- {luv_cli-0.0.16 → luv_cli-0.0.18}/pyproject.toml +1 -1
- {luv_cli-0.0.16 → luv_cli-0.0.18}/.claude/settings.json +0 -0
- {luv_cli-0.0.16 → luv_cli-0.0.18}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.0.16 → luv_cli-0.0.18}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.0.16 → luv_cli-0.0.18}/.gitignore +0 -0
- {luv_cli-0.0.16 → luv_cli-0.0.18}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.0.
|
|
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]
|
|
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",
|
|
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",
|
|
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]
|
|
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",
|
|
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
|
-
|
|
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] =
|
|
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] =
|
|
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
|
-
|
|
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]
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|