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.
- {luv_cli-0.0.17 → luv_cli-0.0.19}/PKG-INFO +2 -1
- {luv_cli-0.0.17 → luv_cli-0.0.19}/README.md +1 -0
- {luv_cli-0.0.17 → luv_cli-0.0.19}/luv/__init__.py +63 -24
- {luv_cli-0.0.17 → luv_cli-0.0.19}/pyproject.toml +1 -1
- {luv_cli-0.0.17 → luv_cli-0.0.19}/.claude/settings.json +0 -0
- {luv_cli-0.0.17 → luv_cli-0.0.19}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.0.17 → luv_cli-0.0.19}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.0.17 → luv_cli-0.0.19}/.gitignore +0 -0
- {luv_cli-0.0.17 → luv_cli-0.0.19}/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.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]
|
|
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",
|
|
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",
|
|
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]
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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] =
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|