luv-cli 0.0.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,617 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ LUV_DIR = Path.home() / ".luv"
11
+ CONFIG_FILE = LUV_DIR / "config.json"
12
+ PRS_DIR = Path.home() / "prs"
13
+ CLAUDE_JSON = Path.home() / ".claude.json"
14
+
15
+ PR_RULES = """
16
+ # Pull Request Management
17
+
18
+ One PR per folder. Each folder maps to exactly one PR — create it once, then keep updating it across subsequent tasks.
19
+
20
+ ## Rules
21
+
22
+ - Before creating a PR, check if one already exists for that folder (by title or branch name convention).
23
+ - If no PR exists for the folder: create one, then record its URL/number so it can be reused.
24
+ - If a PR already exists for the folder: push new commits to the same branch and do NOT open a new PR.
25
+ - PR titles should clearly identify the folder they cover (e.g. `[folder-name] ...`).
26
+ - Never open a second PR for the same folder — always update the existing one.
27
+ """
28
+
29
+
30
+ def die(msg: str) -> None:
31
+ print(f"luv: error: {msg}", file=sys.stderr)
32
+ sys.exit(1)
33
+
34
+
35
+ def run(cmd: list[str], *, cwd: str | None = None) -> subprocess.CompletedProcess:
36
+ return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
37
+
38
+
39
+ def load_config() -> dict:
40
+ """Read ~/.luv/config.json, or return {} on missing/corrupt."""
41
+ if not CONFIG_FILE.exists():
42
+ return {}
43
+ try:
44
+ return json.loads(CONFIG_FILE.read_text())
45
+ except (json.JSONDecodeError, OSError):
46
+ return {}
47
+
48
+
49
+ def save_config(data: dict) -> None:
50
+ """Atomic-write config JSON to ~/.luv/config.json."""
51
+ LUV_DIR.mkdir(parents=True, exist_ok=True)
52
+ with tempfile.NamedTemporaryFile(
53
+ "w", encoding="utf-8", dir=str(LUV_DIR), delete=False,
54
+ ) as tmp:
55
+ json.dump(data, tmp, indent=2)
56
+ tmp.write("\n")
57
+ tmp_path = Path(tmp.name)
58
+ os.replace(tmp_path, CONFIG_FILE)
59
+
60
+
61
+ def parse_github_remote(cwd: str) -> tuple[str, str] | None:
62
+ """Extract (org, repo) from origin remote URL. Returns None on failure."""
63
+ r = run(["git", "remote", "get-url", "origin"], cwd=cwd)
64
+ if r.returncode != 0:
65
+ return None
66
+ url = r.stdout.strip()
67
+ m = re.match(r"https://github\.com/([^/]+)/([^/.]+)", url)
68
+ if not m:
69
+ m = re.match(r"git@github\.com:([^/]+)/([^/.]+)", url)
70
+ if m:
71
+ return m.group(1), m.group(2)
72
+ return None
73
+
74
+
75
+ def resolve_org(explicit: str | None = None) -> str:
76
+ """Resolve GitHub org: explicit arg > config file > error."""
77
+ if explicit:
78
+ return explicit
79
+ cfg = load_config()
80
+ org = cfg.get("org")
81
+ if org:
82
+ return org
83
+ die("no default org configured.\nRun 'luv --init' to set one, or use 'org/repo' syntax.")
84
+ return "" # unreachable, keeps type checkers happy
85
+
86
+
87
+ def trust_project(path: Path) -> None:
88
+ data: dict[str, object] = {}
89
+ if CLAUDE_JSON.exists():
90
+ try:
91
+ with CLAUDE_JSON.open("r", encoding="utf-8") as f:
92
+ loaded = json.load(f)
93
+ if isinstance(loaded, dict):
94
+ data = loaded
95
+ except (json.JSONDecodeError, OSError):
96
+ data = {}
97
+
98
+ projects = data.get("projects")
99
+ if not isinstance(projects, dict):
100
+ projects = {}
101
+ data["projects"] = projects
102
+
103
+ entry = projects.get(str(path))
104
+ if not isinstance(entry, dict):
105
+ entry = {}
106
+ projects[str(path)] = entry
107
+
108
+ entry["hasTrustDialogAccepted"] = True
109
+ CLAUDE_JSON.parent.mkdir(parents=True, exist_ok=True)
110
+ with tempfile.NamedTemporaryFile(
111
+ "w",
112
+ encoding="utf-8",
113
+ dir=str(CLAUDE_JSON.parent),
114
+ delete=False,
115
+ ) as tmp:
116
+ json.dump(data, tmp, indent=2)
117
+ tmp.write("\n")
118
+ tmp_path = Path(tmp.name)
119
+ os.replace(tmp_path, CLAUDE_JSON)
120
+
121
+
122
+ def ensure_pr_rules() -> None:
123
+ claude_dir = Path.home() / ".claude"
124
+ claude_md = claude_dir / "CLAUDE.md"
125
+ claude_dir.mkdir(parents=True, exist_ok=True)
126
+ existing = claude_md.read_text() if claude_md.exists() else ""
127
+ if "# Pull Request Management" not in existing:
128
+ with claude_md.open("a") as f:
129
+ f.write(PR_RULES)
130
+
131
+
132
+ def cmd_init() -> None:
133
+ """Interactive setup: choose a default GitHub org."""
134
+ if not sys.stdin.isatty():
135
+ die("--init requires an interactive terminal")
136
+
137
+ r = run(["gh", "api", "user", "--jq", ".login"])
138
+ if r.returncode != 0:
139
+ die("'gh' not found or not authenticated. Run 'gh auth login' first.")
140
+ username = r.stdout.strip()
141
+
142
+ r = run(["gh", "api", "user/orgs", "--jq", ".[].login"])
143
+ orgs = [line for line in r.stdout.strip().splitlines() if line] if r.returncode == 0 else []
144
+
145
+ choices = [f"{username} (personal)"] + orgs
146
+ print("luv: select default GitHub owner:")
147
+ for i, name in enumerate(choices, 1):
148
+ print(f" {i}) {name}")
149
+ other_idx = len(choices) + 1
150
+ print(f" {other_idx}) other (type manually)")
151
+
152
+ raw = input(f"Choice [1]: ").strip()
153
+ if not raw:
154
+ idx = 1
155
+ else:
156
+ try:
157
+ idx = int(raw)
158
+ except ValueError:
159
+ die(f"invalid choice: '{raw}'")
160
+
161
+ if idx == other_idx:
162
+ selected = input("GitHub org or username: ").strip()
163
+ if not selected:
164
+ die("no org entered")
165
+ elif 1 <= idx <= len(choices):
166
+ selected = choices[idx - 1].split(" (")[0] # strip " (personal)" suffix
167
+ else:
168
+ die(f"invalid choice: {idx}")
169
+
170
+ config = load_config()
171
+ config["org"] = selected
172
+ save_config(config)
173
+ print(f"luv: default org set to '{selected}'. Saved to ~/.luv/config.json")
174
+
175
+
176
+ def load_luv_settings(clone_dir: Path) -> dict | None:
177
+ """Read .luv/settings.json from the repo, or return None."""
178
+ settings_file = clone_dir / ".luv" / "settings.json"
179
+ if not settings_file.exists():
180
+ return None
181
+ try:
182
+ return json.loads(settings_file.read_text())
183
+ except (json.JSONDecodeError, OSError):
184
+ return None
185
+
186
+
187
+ def docker_project_name(clone_dir: Path) -> str:
188
+ """Unique Compose project name — scopes networks and volumes."""
189
+ return f"luv-{clone_dir.name}"
190
+
191
+
192
+ def docker_compose_base(clone_dir: Path, compose_file: str, project: str) -> list[str]:
193
+ """Base docker compose command with project directory and file."""
194
+ return ["docker", "compose", "-f", str(clone_dir / compose_file),
195
+ "--project-directory", str(clone_dir), "-p", project]
196
+
197
+
198
+ def start_docker(clone_dir: Path, compose_file: str, project: str) -> None:
199
+ """Start a fresh Docker Compose environment with isolated network/volumes."""
200
+ compose_path = clone_dir / compose_file
201
+ if not compose_path.exists():
202
+ die(f"compose file not found: {compose_file}")
203
+
204
+ base = docker_compose_base(clone_dir, compose_file, project)
205
+
206
+ # Tear down stale environment (ignore errors if nothing exists)
207
+ subprocess.run(base + ["down", "-v", "--remove-orphans"], capture_output=True)
208
+
209
+ # Start fresh
210
+ print(f"luv: starting docker environment ({project})...")
211
+ r = subprocess.run(base + ["up", "-d", "--build"])
212
+ if r.returncode != 0:
213
+ die("docker compose up failed")
214
+
215
+ # Verify dev-environment service is running
216
+ r = subprocess.run(base + ["ps", "--format", "json", "dev-environment"],
217
+ capture_output=True, text=True)
218
+ if r.returncode != 0 or "running" not in r.stdout.lower():
219
+ subprocess.run(base + ["logs", "dev-environment"])
220
+ die("'dev-environment' service is not running")
221
+
222
+ print("luv: docker environment ready")
223
+
224
+
225
+ def stop_docker(clone_dir: Path, compose_file: str, project: str) -> None:
226
+ """Tear down Docker Compose environment, removing volumes and orphans."""
227
+ base = docker_compose_base(clone_dir, compose_file, project)
228
+ print(f"luv: tearing down docker environment ({project})...")
229
+ subprocess.run(base + ["down", "-v", "--remove-orphans"])
230
+
231
+
232
+ def navigate(clone_dir: Path) -> None:
233
+ """Chdir into the work folder and exec a shell — replacing this process."""
234
+ os.chdir(str(clone_dir))
235
+ settings = load_luv_settings(clone_dir)
236
+ compose_file = (settings or {}).get("compose_file")
237
+
238
+ if compose_file:
239
+ project = docker_project_name(clone_dir)
240
+ start_docker(clone_dir, compose_file, project)
241
+ try:
242
+ base = docker_compose_base(clone_dir, compose_file, project)
243
+ r = subprocess.run(base + ["exec", "-it", "dev-environment", "bash"])
244
+ sys.exit(r.returncode)
245
+ finally:
246
+ stop_docker(clone_dir, compose_file, project)
247
+ else:
248
+ shell = os.environ.get("SHELL", "/bin/bash")
249
+ os.execv(shell, [shell])
250
+
251
+
252
+ def resume(clone_dir: Path) -> None:
253
+ """Trust, chdir, and exec claude --resume — replacing this process."""
254
+ trust_project(clone_dir)
255
+ os.chdir(str(clone_dir))
256
+ settings = load_luv_settings(clone_dir)
257
+ compose_file = (settings or {}).get("compose_file")
258
+
259
+ if compose_file:
260
+ project = docker_project_name(clone_dir)
261
+ start_docker(clone_dir, compose_file, project)
262
+ try:
263
+ base = docker_compose_base(clone_dir, compose_file, project)
264
+ r = subprocess.run(base + ["exec", "-it", "dev-environment",
265
+ "claude", "--dangerously-skip-permissions",
266
+ "--model", "claude-opus-4-6",
267
+ "--effort", "max", "--resume"])
268
+ sys.exit(r.returncode)
269
+ finally:
270
+ stop_docker(clone_dir, compose_file, project)
271
+ else:
272
+ claude_bin = shutil.which("claude")
273
+ if not claude_bin:
274
+ die("'claude' not found in PATH")
275
+ os.execv(claude_bin, [claude_bin, "--dangerously-skip-permissions",
276
+ "--model", "claude-opus-4-6", "--effort", "max", "--resume"])
277
+
278
+
279
+ def launch(clone_dir: Path, prompt: str | None) -> None:
280
+ """Trust, resolve claude, chdir, and exec — replacing this process."""
281
+ trust_project(clone_dir)
282
+ os.chdir(str(clone_dir))
283
+ settings = load_luv_settings(clone_dir)
284
+ compose_file = (settings or {}).get("compose_file")
285
+
286
+ if compose_file:
287
+ project = docker_project_name(clone_dir)
288
+ start_docker(clone_dir, compose_file, project)
289
+ try:
290
+ base = docker_compose_base(clone_dir, compose_file, project)
291
+ claude_cmd = ["claude", "--dangerously-skip-permissions",
292
+ "--permission-mode", "bypassPermissions",
293
+ "--model", "claude-opus-4-6", "--effort", "max"]
294
+ if prompt:
295
+ claude_cmd.append(f"/plan {prompt}")
296
+ r = subprocess.run(base + ["exec", "-it", "dev-environment"] + claude_cmd)
297
+ sys.exit(r.returncode)
298
+ finally:
299
+ stop_docker(clone_dir, compose_file, project)
300
+ else:
301
+ claude_bin = shutil.which("claude")
302
+ if not claude_bin:
303
+ die("'claude' not found in PATH")
304
+ base_args = [claude_bin, "--dangerously-skip-permissions",
305
+ "--permission-mode", "bypassPermissions",
306
+ "--model", "claude-opus-4-6", "--effort", "max"]
307
+ if prompt:
308
+ os.execv(claude_bin, base_args + [f"/plan {prompt}"])
309
+ else:
310
+ os.execv(claude_bin, base_args)
311
+
312
+
313
+ def cmd_clean(force: bool = False) -> None:
314
+ """Scan ~/prs/ and delete fully-pushed, clean work folders."""
315
+ if not PRS_DIR.exists():
316
+ print("luv: nothing to clean (~/prs/ does not exist)")
317
+ return
318
+
319
+ cleaned: list[str] = []
320
+ skipped: list[tuple[str, str]] = []
321
+
322
+ for entry in sorted(PRS_DIR.iterdir()):
323
+ if not entry.is_dir():
324
+ continue
325
+
326
+ parts = entry.name.rsplit("-", 1)
327
+ if len(parts) != 2 or not parts[1].isdigit():
328
+ continue # doesn't match {repo}-{number} — skip silently
329
+
330
+ if force:
331
+ shutil.rmtree(entry)
332
+ cleaned.append(entry.name)
333
+ continue
334
+
335
+ number_str = parts[1]
336
+ branch = f"luv-{number_str}"
337
+ cwd = str(entry)
338
+
339
+ # Must be a git repo
340
+ if run(["git", "rev-parse", "--git-dir"], cwd=cwd).returncode != 0:
341
+ continue
342
+
343
+ # 1. Working tree must be clean
344
+ r = run(["git", "status", "--porcelain"], cwd=cwd)
345
+ if r.returncode != 0 or r.stdout.strip():
346
+ skipped.append((entry.name, "uncommitted changes"))
347
+ continue
348
+
349
+ # 2. Fetch remote branch; if gone, check for a merged PR
350
+ fetch_ok = run(["git", "fetch", "origin", branch], cwd=cwd).returncode == 0
351
+
352
+ if not fetch_ok:
353
+ remote_info = parse_github_remote(cwd)
354
+ if remote_info is None:
355
+ skipped.append((entry.name, "cannot determine org from git remote"))
356
+ continue
357
+ remote_org, repo_name = remote_info
358
+ r = run(["gh", "api", f"repos/{remote_org}/{repo_name}/pulls",
359
+ "-f", "state=closed", "-f", f"head={remote_org}:{branch}",
360
+ "-f", "per_page=5"])
361
+ if r.returncode != 0:
362
+ skipped.append((entry.name, "branch not on remote"))
363
+ continue
364
+ prs = json.loads(r.stdout)
365
+ merged = [pr for pr in prs if pr.get("merged_at")]
366
+ if not merged:
367
+ skipped.append((entry.name, "branch not on remote"))
368
+ continue
369
+ pr_head_sha = merged[0]["head"]["sha"]
370
+ local_sha = run(["git", "rev-parse", "HEAD"], cwd=cwd).stdout.strip()
371
+ if local_sha != pr_head_sha:
372
+ skipped.append((entry.name, "local HEAD differs from merged PR head"))
373
+ continue
374
+ shutil.rmtree(entry)
375
+ cleaned.append(entry.name)
376
+ continue
377
+
378
+ # 3. No unpushed commits (branch still exists on remote)
379
+ r = run(["git", "rev-list", f"origin/{branch}..HEAD", "--count"], cwd=cwd)
380
+ if r.returncode != 0 or r.stdout.strip() != "0":
381
+ skipped.append((entry.name, "unpushed commits"))
382
+ continue
383
+
384
+ shutil.rmtree(entry)
385
+ cleaned.append(entry.name)
386
+
387
+ if skipped:
388
+ print("luv: skipped (not clean):")
389
+ for name, reason in skipped:
390
+ print(f" {name}: {reason}")
391
+
392
+ if cleaned:
393
+ print("luv: cleaned:")
394
+ for name in cleaned:
395
+ print(f" {name}")
396
+
397
+ if not skipped and not cleaned:
398
+ print("luv: nothing to clean")
399
+
400
+
401
+ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False) -> None:
402
+ """Open an existing work folder or remote branch by number."""
403
+ clone_dir = PRS_DIR / f"{repo}-{number}"
404
+
405
+ # 1. Local folder takes priority
406
+ if clone_dir.exists():
407
+ print(f"luv: opening existing folder {clone_dir.name}")
408
+ ensure_pr_rules()
409
+ if nav_mode:
410
+ navigate(clone_dir)
411
+ elif resume_mode:
412
+ resume(clone_dir)
413
+ else:
414
+ launch(clone_dir, prompt)
415
+ return # unreachable
416
+
417
+ # 2. Check remote branch luv-{number}
418
+ branch = f"luv-{number}"
419
+ clone_url = f"https://github.com/{org}/{repo}"
420
+ r = run(["git", "ls-remote", "--heads", clone_url, branch])
421
+ if branch not in r.stdout:
422
+ die(f"no local folder '{repo}-{number}' and no remote branch '{branch}'")
423
+
424
+ # 3. Clone and checkout the existing branch
425
+ PRS_DIR.mkdir(parents=True, exist_ok=True)
426
+ print(f"luv: cloning {clone_url} -> {clone_dir} (branch {branch})")
427
+ r = subprocess.run(["git", "clone", clone_url, str(clone_dir)])
428
+ if r.returncode != 0:
429
+ die(f"git clone failed (exit {r.returncode})")
430
+ r = subprocess.run(["git", "checkout", branch], cwd=str(clone_dir))
431
+ if r.returncode != 0:
432
+ die(f"git checkout {branch} failed (exit {r.returncode})")
433
+
434
+ print(f"luv: ready — {clone_dir.name}, branch {branch}")
435
+ ensure_pr_rules()
436
+ if nav_mode:
437
+ navigate(clone_dir)
438
+ elif resume_mode:
439
+ resume(clone_dir)
440
+ else:
441
+ launch(clone_dir, prompt)
442
+
443
+
444
+ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False) -> None:
445
+ """Open any GitHub PR by org/repo/number, cloning if needed."""
446
+ clone_dir = PRS_DIR / f"{repo}-{number}"
447
+
448
+ if clone_dir.exists():
449
+ print(f"luv: opening existing folder {clone_dir.name}")
450
+ ensure_pr_rules()
451
+ if nav_mode:
452
+ navigate(clone_dir)
453
+ elif resume_mode:
454
+ resume(clone_dir)
455
+ else:
456
+ launch(clone_dir, prompt)
457
+ return # unreachable
458
+
459
+ # Resolve the actual branch name via GitHub API
460
+ r = run(["gh", "api", f"repos/{org}/{repo}/pulls/{number}"])
461
+ if r.returncode != 0:
462
+ die(f"PR {org}/{repo}#{number} not found.\n{r.stderr.strip()}")
463
+ pr_data = json.loads(r.stdout)
464
+ branch = pr_data["head"]["ref"]
465
+ clone_url = pr_data["head"]["repo"]["clone_url"]
466
+
467
+ PRS_DIR.mkdir(parents=True, exist_ok=True)
468
+ print(f"luv: cloning {clone_url} -> {clone_dir} (branch {branch})")
469
+ r = subprocess.run(["git", "clone", clone_url, str(clone_dir)])
470
+ if r.returncode != 0:
471
+ die(f"git clone failed (exit {r.returncode})")
472
+ r = subprocess.run(["git", "checkout", branch], cwd=str(clone_dir))
473
+ if r.returncode != 0:
474
+ die(f"git checkout {branch} failed (exit {r.returncode})")
475
+
476
+ print(f"luv: ready — {clone_dir.name}, branch {branch}")
477
+ ensure_pr_rules()
478
+ if nav_mode:
479
+ navigate(clone_dir)
480
+ elif resume_mode:
481
+ resume(clone_dir)
482
+ else:
483
+ launch(clone_dir, prompt)
484
+
485
+
486
+ def main() -> None:
487
+ args = sys.argv[1:]
488
+
489
+ nav_mode = "-n" in args
490
+ resume_mode = "-r" in args
491
+ force = "-f" in args or "--force" in args
492
+ args = [a for a in args if a not in ("-n", "-r", "-f", "--force")]
493
+
494
+ if not args or args[0] in ("-h", "--help"):
495
+ print("""\
496
+ Usage: luv [flags] <command>
497
+
498
+ Flags:
499
+ -n navigate: open a shell in the work folder instead of launching Claude
500
+ -r resume: resume the last Claude session in the work folder
501
+ -f, --force (with --clean) skip safety checks and delete all work folders
502
+
503
+ Commands:
504
+ luv --init configure default GitHub org
505
+ luv [org/]<repo> [prompt...] create a new PR workspace
506
+ luv [org/]<repo> <number> [prompt] reopen an existing work folder by number
507
+ luv -l <PR URL> [prompt] open any GitHub PR by URL
508
+ luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number
509
+ luv --clean [-f] delete fully-pushed work folders
510
+
511
+ Org resolution:
512
+ Explicit org/repo overrides the default. Run 'luv --init' to set a default.
513
+ Config: ~/.luv/config.json
514
+
515
+ Docker:
516
+ If the repo contains .luv/settings.json with a "compose_file" key,
517
+ luv starts a Docker Compose environment and runs Claude inside the
518
+ "dev-environment" service. Torn down automatically on exit.""")
519
+ sys.exit(0)
520
+
521
+ if args[0] == "--clean":
522
+ cmd_clean(force=force)
523
+ return
524
+
525
+ if args[0] == "--init":
526
+ cmd_init()
527
+ return
528
+
529
+ # luv -l <PR URL>
530
+ if args[0] == "-l":
531
+ if len(args) < 2:
532
+ die("usage: luv -l <PR URL>")
533
+ url = args[1]
534
+ m = re.match(r"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)", url)
535
+ if not m:
536
+ die(f"cannot parse PR URL: {url}")
537
+ org, repo, number = m.group(1), m.group(2), int(m.group(3))
538
+ prompt = " ".join(args[2:]) or None
539
+ open_pr(org, repo, number, prompt, nav_mode, resume_mode)
540
+ return
541
+
542
+ raw = args[0].rstrip("/")
543
+ if "/" in raw:
544
+ explicit_org, repo = raw.split("/", 1)
545
+ else:
546
+ explicit_org, repo = None, raw
547
+
548
+ # luv [org/]<repo> -pr <number>
549
+ if "-pr" in args:
550
+ idx = args.index("-pr")
551
+ if idx + 1 >= len(args):
552
+ die("usage: luv <repo> -pr <number>")
553
+ try:
554
+ number = int(args[idx + 1])
555
+ except ValueError:
556
+ die(f"expected a PR number after -pr, got '{args[idx + 1]}'")
557
+ prompt_parts = [a for i, a in enumerate(args) if i not in (0, idx, idx + 1)]
558
+ prompt = " ".join(prompt_parts) or None
559
+ open_pr(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode)
560
+ return
561
+
562
+ # Detect optional numeric second argument
563
+ if len(args) > 1 and args[1].isdigit():
564
+ number = int(args[1])
565
+ prompt = " ".join(args[2:]) or None
566
+ open_existing(resolve_org(explicit_org), repo, number, prompt, nav_mode, resume_mode)
567
+ return
568
+
569
+ org = resolve_org(explicit_org)
570
+ prompt = " ".join(args[1:]) if len(args) > 1 else None
571
+
572
+ # 1. Verify repo exists
573
+ r = run(["gh", "api", f"repos/{org}/{repo}"])
574
+ if r.returncode != 0:
575
+ die(f"repo '{org}/{repo}' not found or gh auth failed.\n{r.stderr.strip()}")
576
+
577
+ # 2. Get latest issue/PR number (shared counter on GitHub)
578
+ r = run(["gh", "api",
579
+ f"repos/{org}/{repo}/issues?state=all&per_page=1&sort=created&direction=desc"])
580
+ if r.returncode != 0:
581
+ die(f"failed to fetch issues.\n{r.stderr.strip()}")
582
+ items = json.loads(r.stdout)
583
+ latest = items[0]["number"] if items else 0
584
+ candidate = latest + 1
585
+
586
+ # 3. Find free local folder
587
+ PRS_DIR.mkdir(parents=True, exist_ok=True)
588
+ while (PRS_DIR / f"{repo}-{candidate}").exists():
589
+ candidate += 1
590
+ clone_dir = PRS_DIR / f"{repo}-{candidate}"
591
+
592
+ # 4. Clone
593
+ clone_url = f"https://github.com/{org}/{repo}"
594
+ print(f"luv: cloning {clone_url} -> {clone_dir}")
595
+ r = subprocess.run(["git", "clone", clone_url, str(clone_dir)])
596
+ if r.returncode != 0:
597
+ die(f"git clone failed (exit {r.returncode})")
598
+
599
+ # 5. Create branch
600
+ branch = f"luv-{candidate}"
601
+ print(f"luv: creating branch {branch}")
602
+ r = subprocess.run(["git", "checkout", "-b", branch], cwd=str(clone_dir))
603
+ if r.returncode != 0:
604
+ die(f"git checkout -b failed (exit {r.returncode})")
605
+
606
+ # 6. Ensure PR rules in ~/.claude/CLAUDE.md
607
+ ensure_pr_rules()
608
+
609
+ print(f"luv: ready — {clone_dir.name}, branch {branch}")
610
+
611
+ # 7. Launch claude, resume session, or open shell (replace this process)
612
+ if nav_mode:
613
+ navigate(clone_dir)
614
+ elif resume_mode:
615
+ resume(clone_dir)
616
+ else:
617
+ launch(clone_dir, prompt)
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: luv-cli
3
+ Version: 0.0.1
4
+ Summary: Launch Claude Code agents on GitHub repos with isolated workspaces and optional Docker dev environments
5
+ Project-URL: Homepage, https://github.com/exospherehost/luv
6
+ Project-URL: Repository, https://github.com/exospherehost/luv
7
+ Project-URL: Issues, https://github.com/exospherehost/luv/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,ai,claude,cli,docker,github
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Version Control :: Git
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # luv
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.
27
+
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
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # With uv (recommended)
34
+ uv tool install luv-cli
35
+
36
+ # With pip
37
+ pip install luv-cli
38
+ ```
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.
41
+
42
+ ## Quick start
43
+
44
+ ```bash
45
+ # Configure your default GitHub org (one-time setup)
46
+ luv --init
47
+
48
+ # Create a new workspace and launch Claude
49
+ luv my-repo "add user authentication"
50
+
51
+ # Use a different org inline
52
+ luv other-org/my-repo "fix the bug"
53
+
54
+ # Reopen workspace #42
55
+ luv my-repo 42
56
+
57
+ # Open any GitHub PR by URL
58
+ luv -l https://github.com/org/repo/pull/123
59
+
60
+ # Open a shell instead of Claude
61
+ luv -n my-repo 42
62
+
63
+ # Resume last Claude session
64
+ luv -r my-repo 42
65
+
66
+ # Clean up fully-merged workspaces
67
+ luv --clean
68
+ ```
69
+
70
+ ## How it works
71
+
72
+ 1. Clones the repo into `~/prs/{repo}-{number}/`
73
+ 2. Creates a new branch `luv-{number}`
74
+ 3. Trusts the project in Claude Code config
75
+ 4. Launches Claude with Opus and max effort
76
+
77
+ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
78
+
79
+ ## Commands
80
+
81
+ | Command | Description |
82
+ |---------|-------------|
83
+ | `luv --init` | Configure default GitHub org |
84
+ | `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude |
85
+ | `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
86
+ | `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
87
+ | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
88
+ | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
89
+ | `luv --clean -f` | Force delete all workspaces |
90
+
91
+ ### Flags
92
+
93
+ | Flag | Description |
94
+ |------|-------------|
95
+ | `-n` | Navigate: open a shell instead of Claude |
96
+ | `-r` | Resume: resume the last Claude session |
97
+ | `-f`, `--force` | Skip safety checks (with `--clean`) |
98
+
99
+ ## Docker dev environments
100
+
101
+ If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
102
+
103
+ ### Setup
104
+
105
+ **1. Create `.luv/settings.json` in your repo:**
106
+
107
+ ```json
108
+ {
109
+ "compose_file": ".luv/docker-compose.yml"
110
+ }
111
+ ```
112
+
113
+ The `compose_file` path is relative to the repo root.
114
+
115
+ **2. Create the Docker Compose file:**
116
+
117
+ ```yaml
118
+ services:
119
+ dev-environment:
120
+ image: your-org/dev-env:latest
121
+ volumes:
122
+ - .:/workspace
123
+ working_dir: /workspace
124
+ stdin_open: true
125
+ tty: true
126
+ depends_on:
127
+ - postgres
128
+
129
+ postgres:
130
+ image: postgres:16
131
+ environment:
132
+ POSTGRES_PASSWORD: dev
133
+ ```
134
+
135
+ The `dev-environment` service **must** have [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed in its image.
136
+
137
+ ### How Docker mode works
138
+
139
+ 1. Detects `.luv/settings.json` with `compose_file` key
140
+ 2. Tears down any stale environment from a previous run
141
+ 3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
142
+ 4. Verifies the `dev-environment` service is running
143
+ 5. Runs Claude inside the container via `docker compose exec`
144
+ 6. The repo is volume-mounted, so all file changes and git commits are visible on the host
145
+ 7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
146
+
147
+ Docker mode works with all flags: `-n` opens a bash shell in the container, `-r` resumes a Claude session in the container.
148
+
149
+ ## Workspace cleanup
150
+
151
+ `luv --clean` scans `~/prs/` and safely removes workspaces that are fully pushed. It checks:
152
+
153
+ - Working tree is clean (no uncommitted changes)
154
+ - No unpushed commits
155
+ - If the remote branch is gone, verifies the PR was merged and local HEAD matches
156
+
157
+ Use `luv --clean -f` to skip all safety checks and delete everything.
158
+
159
+ ## Configuration
160
+
161
+ Run `luv --init` to set your default GitHub org. This saves to `~/.luv/config.json`.
162
+
163
+ You can also pass `org/repo` inline to override the default for any command (e.g., `luv other-org/my-repo`).
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,6 @@
1
+ luv/__init__.py,sha256=2hOl4-EpdejxJ1AAXU4Pcuqp3sUj8Yt-uVM7uGKAgNE,22186
2
+ luv_cli-0.0.1.dist-info/METADATA,sha256=_oXc7YsG_AH-eItyUvYqT7tGjyBGu4NB2xnb9Gb0zmk,5322
3
+ luv_cli-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ luv_cli-0.0.1.dist-info/entry_points.txt,sha256=VbTyzmSKBGZ1lsMy6n6zS9yKLCA0vuuwKMVSigp4ckc,33
5
+ luv_cli-0.0.1.dist-info/licenses/LICENSE,sha256=0iCTP0JdiOu_Wm41eWG90PWlHuErV_KEnn3FyevlUGQ,1071
6
+ luv_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ luv = luv:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Exosphere Host
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.