luv-cli 0.0.1__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.1/.github/workflows/publish.yml +29 -0
- luv_cli-0.0.1/LICENSE +21 -0
- luv_cli-0.0.1/PKG-INFO +167 -0
- luv_cli-0.0.1/README.md +144 -0
- luv_cli-0.0.1/luv/__init__.py +617 -0
- luv_cli-0.0.1/pyproject.toml +36 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
luv_cli-0.0.1/LICENSE
ADDED
|
@@ -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.
|
luv_cli-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
luv_cli-0.0.1/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# luv
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
`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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# With uv (recommended)
|
|
11
|
+
uv tool install luv-cli
|
|
12
|
+
|
|
13
|
+
# With pip
|
|
14
|
+
pip install luv-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Configure your default GitHub org (one-time setup)
|
|
23
|
+
luv --init
|
|
24
|
+
|
|
25
|
+
# Create a new workspace and launch Claude
|
|
26
|
+
luv my-repo "add user authentication"
|
|
27
|
+
|
|
28
|
+
# Use a different org inline
|
|
29
|
+
luv other-org/my-repo "fix the bug"
|
|
30
|
+
|
|
31
|
+
# Reopen workspace #42
|
|
32
|
+
luv my-repo 42
|
|
33
|
+
|
|
34
|
+
# Open any GitHub PR by URL
|
|
35
|
+
luv -l https://github.com/org/repo/pull/123
|
|
36
|
+
|
|
37
|
+
# Open a shell instead of Claude
|
|
38
|
+
luv -n my-repo 42
|
|
39
|
+
|
|
40
|
+
# Resume last Claude session
|
|
41
|
+
luv -r my-repo 42
|
|
42
|
+
|
|
43
|
+
# Clean up fully-merged workspaces
|
|
44
|
+
luv --clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. Clones the repo into `~/prs/{repo}-{number}/`
|
|
50
|
+
2. Creates a new branch `luv-{number}`
|
|
51
|
+
3. Trusts the project in Claude Code config
|
|
52
|
+
4. Launches Claude with Opus and max effort
|
|
53
|
+
|
|
54
|
+
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Description |
|
|
59
|
+
|---------|-------------|
|
|
60
|
+
| `luv --init` | Configure default GitHub org |
|
|
61
|
+
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude |
|
|
62
|
+
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
63
|
+
| `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
|
|
64
|
+
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
|
|
65
|
+
| `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
|
|
66
|
+
| `luv --clean -f` | Force delete all workspaces |
|
|
67
|
+
|
|
68
|
+
### Flags
|
|
69
|
+
|
|
70
|
+
| Flag | Description |
|
|
71
|
+
|------|-------------|
|
|
72
|
+
| `-n` | Navigate: open a shell instead of Claude |
|
|
73
|
+
| `-r` | Resume: resume the last Claude session |
|
|
74
|
+
| `-f`, `--force` | Skip safety checks (with `--clean`) |
|
|
75
|
+
|
|
76
|
+
## Docker dev environments
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
### Setup
|
|
81
|
+
|
|
82
|
+
**1. Create `.luv/settings.json` in your repo:**
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"compose_file": ".luv/docker-compose.yml"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The `compose_file` path is relative to the repo root.
|
|
91
|
+
|
|
92
|
+
**2. Create the Docker Compose file:**
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
services:
|
|
96
|
+
dev-environment:
|
|
97
|
+
image: your-org/dev-env:latest
|
|
98
|
+
volumes:
|
|
99
|
+
- .:/workspace
|
|
100
|
+
working_dir: /workspace
|
|
101
|
+
stdin_open: true
|
|
102
|
+
tty: true
|
|
103
|
+
depends_on:
|
|
104
|
+
- postgres
|
|
105
|
+
|
|
106
|
+
postgres:
|
|
107
|
+
image: postgres:16
|
|
108
|
+
environment:
|
|
109
|
+
POSTGRES_PASSWORD: dev
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The `dev-environment` service **must** have [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed in its image.
|
|
113
|
+
|
|
114
|
+
### How Docker mode works
|
|
115
|
+
|
|
116
|
+
1. Detects `.luv/settings.json` with `compose_file` key
|
|
117
|
+
2. Tears down any stale environment from a previous run
|
|
118
|
+
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
|
|
119
|
+
4. Verifies the `dev-environment` service is running
|
|
120
|
+
5. Runs Claude inside the container via `docker compose exec`
|
|
121
|
+
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
|
|
122
|
+
7. On exit (including Ctrl-C), tears down the environment with `docker compose down -v`
|
|
123
|
+
|
|
124
|
+
Docker mode works with all flags: `-n` opens a bash shell in the container, `-r` resumes a Claude session in the container.
|
|
125
|
+
|
|
126
|
+
## Workspace cleanup
|
|
127
|
+
|
|
128
|
+
`luv --clean` scans `~/prs/` and safely removes workspaces that are fully pushed. It checks:
|
|
129
|
+
|
|
130
|
+
- Working tree is clean (no uncommitted changes)
|
|
131
|
+
- No unpushed commits
|
|
132
|
+
- If the remote branch is gone, verifies the PR was merged and local HEAD matches
|
|
133
|
+
|
|
134
|
+
Use `luv --clean -f` to skip all safety checks and delete everything.
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
Run `luv --init` to set your default GitHub org. This saves to `~/.luv/config.json`.
|
|
139
|
+
|
|
140
|
+
You can also pass `org/repo` inline to override the default for any command (e.g., `luv other-org/my-repo`).
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -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,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "luv-cli"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Launch Claude Code agents on GitHub repos with isolated workspaces and optional Docker dev environments"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = []
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
keywords = ["claude", "cli", "github", "ai", "agent", "docker"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/exospherehost/luv"
|
|
29
|
+
Repository = "https://github.com/exospherehost/luv"
|
|
30
|
+
Issues = "https://github.com/exospherehost/luv/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["luv"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
luv = "luv:main"
|