claude-launcher 0.1.0__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.
- claude_launcher/__init__.py +3 -0
- claude_launcher/__main__.py +6 -0
- claude_launcher/cli.py +199 -0
- claude_launcher/core/__init__.py +1 -0
- claude_launcher/core/config.py +250 -0
- claude_launcher/core/discovery.py +97 -0
- claude_launcher/core/models.py +129 -0
- claude_launcher/core/storage.py +247 -0
- claude_launcher/ui/__init__.py +1 -0
- claude_launcher/ui/_preview_helper.py +72 -0
- claude_launcher/ui/browser.py +196 -0
- claude_launcher/ui/preview.py +265 -0
- claude_launcher/ui/selector.py +272 -0
- claude_launcher/utils/__init__.py +1 -0
- claude_launcher/utils/git.py +148 -0
- claude_launcher/utils/logging.py +79 -0
- claude_launcher/utils/paths.py +61 -0
- claude_launcher-0.1.0.dist-info/METADATA +332 -0
- claude_launcher-0.1.0.dist-info/RECORD +23 -0
- claude_launcher-0.1.0.dist-info/WHEEL +5 -0
- claude_launcher-0.1.0.dist-info/entry_points.txt +2 -0
- claude_launcher-0.1.0.dist-info/licenses/LICENSE +21 -0
- claude_launcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Preview generation for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import subprocess # nosec B404
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from claude_launcher.core.models import PreviewContent, Project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_preview(
|
|
11
|
+
project_path: Path, show_git_status: bool = True
|
|
12
|
+
) -> PreviewContent:
|
|
13
|
+
"""Generate preview content for a project.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
project_path: Path to the project
|
|
17
|
+
show_git_status: Whether to include git status
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
PreviewContent instance with available information
|
|
21
|
+
"""
|
|
22
|
+
preview = PreviewContent()
|
|
23
|
+
|
|
24
|
+
# Try to read CLAUDE.md
|
|
25
|
+
claude_md_path = project_path / "CLAUDE.md"
|
|
26
|
+
if claude_md_path.exists():
|
|
27
|
+
try:
|
|
28
|
+
with open(claude_md_path, "r", encoding="utf-8") as f:
|
|
29
|
+
lines = f.readlines()[:20] # First 20 lines
|
|
30
|
+
preview.claude_md = "".join(lines).rstrip()
|
|
31
|
+
except Exception as e:
|
|
32
|
+
preview.error = f"Error reading CLAUDE.md: {e}"
|
|
33
|
+
|
|
34
|
+
# Try to get git status
|
|
35
|
+
if show_git_status and (project_path / ".git").exists():
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run( # nosec B603, B607
|
|
38
|
+
["git", "status", "-s"],
|
|
39
|
+
cwd=project_path,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=2,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode == 0:
|
|
45
|
+
status = result.stdout.strip()
|
|
46
|
+
if status:
|
|
47
|
+
preview.git_status = status
|
|
48
|
+
else:
|
|
49
|
+
preview.git_status = "Clean working tree"
|
|
50
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
51
|
+
# Git not available or error - graceful degradation
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# Directory listing as fallback
|
|
55
|
+
try:
|
|
56
|
+
# Separate directories and files
|
|
57
|
+
dirs = []
|
|
58
|
+
files = []
|
|
59
|
+
for item in project_path.iterdir():
|
|
60
|
+
if item.is_dir():
|
|
61
|
+
dirs.append(f"📁 {item.name}/")
|
|
62
|
+
else:
|
|
63
|
+
files.append(f"📄 {item.name}")
|
|
64
|
+
|
|
65
|
+
# Sort each group and combine (folders first, then files)
|
|
66
|
+
items = sorted(dirs) + sorted(files)
|
|
67
|
+
|
|
68
|
+
# Show all items (no limit)
|
|
69
|
+
if items:
|
|
70
|
+
preview.directory_listing = "\n".join(items)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
if not preview.error:
|
|
73
|
+
preview.error = f"Error reading directory: {e}"
|
|
74
|
+
|
|
75
|
+
return preview
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_project_line(
|
|
79
|
+
project_path: Path,
|
|
80
|
+
parent_path: Optional[Path] = None,
|
|
81
|
+
is_git: bool = False,
|
|
82
|
+
is_manual: bool = False,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Format a project for display in the list.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
project_path: Path to the project
|
|
88
|
+
parent_path: Parent directory path (for relative display)
|
|
89
|
+
is_git: Whether this is a git repository
|
|
90
|
+
is_manual: Whether this was manually added
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Formatted string for display
|
|
94
|
+
"""
|
|
95
|
+
markers = []
|
|
96
|
+
if is_git:
|
|
97
|
+
markers.append("git")
|
|
98
|
+
if is_manual:
|
|
99
|
+
markers.append("manual")
|
|
100
|
+
|
|
101
|
+
marker_str = f"[{','.join(markers)}]" if markers else ""
|
|
102
|
+
|
|
103
|
+
# Show relative to parent if available
|
|
104
|
+
if parent_path:
|
|
105
|
+
try:
|
|
106
|
+
rel_path = project_path.relative_to(parent_path)
|
|
107
|
+
display_path = f"{parent_path.name}/{rel_path}"
|
|
108
|
+
except ValueError:
|
|
109
|
+
display_path = str(project_path)
|
|
110
|
+
else:
|
|
111
|
+
display_path = str(project_path)
|
|
112
|
+
|
|
113
|
+
if marker_str:
|
|
114
|
+
return f"{display_path} {marker_str}"
|
|
115
|
+
return display_path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_tree_view(
|
|
119
|
+
projects: List[Project], base_path: Optional[Path] = None
|
|
120
|
+
) -> Tuple[List[str], Dict[str, Project]]:
|
|
121
|
+
"""Build a hierarchical tree view showing full folder structure.
|
|
122
|
+
|
|
123
|
+
Format: Each line contains "absolute_path\t\ttree_display"
|
|
124
|
+
fzf will be configured to only show tree_display but pass the whole line.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
projects: List of projects to display
|
|
128
|
+
base_path: Base path to use for relative display (optional)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Tuple of (formatted_lines, line_to_project_mapping)
|
|
132
|
+
"""
|
|
133
|
+
if not projects:
|
|
134
|
+
return [], {}
|
|
135
|
+
|
|
136
|
+
# Use provided base_path or calculate from projects
|
|
137
|
+
if base_path:
|
|
138
|
+
base = base_path
|
|
139
|
+
else:
|
|
140
|
+
# Find common base path
|
|
141
|
+
if len(projects) == 1:
|
|
142
|
+
base = projects[0].path.parent
|
|
143
|
+
else:
|
|
144
|
+
# Find common ancestor
|
|
145
|
+
all_parts = [p.path.parts for p in projects]
|
|
146
|
+
common_parts = []
|
|
147
|
+
for parts in zip(*all_parts):
|
|
148
|
+
if len(set(parts)) == 1:
|
|
149
|
+
common_parts.append(parts[0])
|
|
150
|
+
else:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
# If we only have root as common, use the parent of the first project as base
|
|
154
|
+
# This avoids showing the entire filesystem
|
|
155
|
+
if not common_parts or (len(common_parts) == 1 and common_parts[0] == "/"):
|
|
156
|
+
# No meaningful common path - use first project's parent
|
|
157
|
+
base = projects[0].path.parent
|
|
158
|
+
else:
|
|
159
|
+
base = Path(*common_parts)
|
|
160
|
+
|
|
161
|
+
# Build full directory tree structure
|
|
162
|
+
# Track all directories and their children (both dirs and projects)
|
|
163
|
+
dir_children: Dict[Path, List[Path]] = {} # dir -> child dirs
|
|
164
|
+
dir_projects: Dict[Path, List[Project]] = {} # dir -> projects in that dir
|
|
165
|
+
|
|
166
|
+
# Collect all directories in the hierarchy
|
|
167
|
+
all_dirs = set()
|
|
168
|
+
for project in projects:
|
|
169
|
+
# Add project to its parent directory
|
|
170
|
+
parent = project.path.parent
|
|
171
|
+
if parent not in dir_projects:
|
|
172
|
+
dir_projects[parent] = []
|
|
173
|
+
dir_projects[parent].append(project)
|
|
174
|
+
|
|
175
|
+
# Add all ancestor directories
|
|
176
|
+
current = project.path.parent
|
|
177
|
+
while current != base and current != current.parent:
|
|
178
|
+
all_dirs.add(current)
|
|
179
|
+
parent_dir = current.parent
|
|
180
|
+
if parent_dir != current and parent_dir != base.parent:
|
|
181
|
+
if parent_dir not in dir_children:
|
|
182
|
+
dir_children[parent_dir] = []
|
|
183
|
+
if current not in dir_children[parent_dir]:
|
|
184
|
+
dir_children[parent_dir].append(current)
|
|
185
|
+
current = parent_dir
|
|
186
|
+
|
|
187
|
+
# Sort children for consistent display
|
|
188
|
+
for parent in dir_children:
|
|
189
|
+
dir_children[parent].sort()
|
|
190
|
+
|
|
191
|
+
formatted_lines = []
|
|
192
|
+
line_to_project = {}
|
|
193
|
+
|
|
194
|
+
def add_directory(
|
|
195
|
+
dir_path: Path, prefix: str = "", is_last: bool = True, depth: int = 0
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Recursively add directory tree."""
|
|
198
|
+
# Show directory header if not the base
|
|
199
|
+
if dir_path != base:
|
|
200
|
+
connector = "└── " if is_last else "├── "
|
|
201
|
+
# Get relative path from base for folder display
|
|
202
|
+
try:
|
|
203
|
+
rel_dir = dir_path.relative_to(base)
|
|
204
|
+
dir_name = str(rel_dir)
|
|
205
|
+
except ValueError:
|
|
206
|
+
# Can't make relative - show full path
|
|
207
|
+
dir_name = str(dir_path)
|
|
208
|
+
|
|
209
|
+
dir_display = f"\033[2m{prefix}{connector}📁 {dir_name}/\033[0m"
|
|
210
|
+
formatted_lines.append(f"{dir_path}\t\t{dir_display}")
|
|
211
|
+
|
|
212
|
+
# Update prefix for children
|
|
213
|
+
if is_last:
|
|
214
|
+
child_prefix = prefix + " "
|
|
215
|
+
else:
|
|
216
|
+
child_prefix = prefix + "│ "
|
|
217
|
+
else:
|
|
218
|
+
# Base directory - only show if it's meaningful (not root)
|
|
219
|
+
if base != Path("/"):
|
|
220
|
+
dir_display = f"\033[2m📁 {base}/\033[0m"
|
|
221
|
+
formatted_lines.append(f"{base}\t\t{dir_display}")
|
|
222
|
+
child_prefix = ""
|
|
223
|
+
|
|
224
|
+
# Get subdirectories and projects
|
|
225
|
+
subdirs = dir_children.get(dir_path, [])
|
|
226
|
+
projects_here = dir_projects.get(dir_path, [])
|
|
227
|
+
|
|
228
|
+
# Sort projects
|
|
229
|
+
projects_here = sorted(projects_here, key=lambda p: p.path.name)
|
|
230
|
+
|
|
231
|
+
# Calculate total items (subdirs + projects)
|
|
232
|
+
total_items = len(subdirs) + len(projects_here)
|
|
233
|
+
item_idx = 0
|
|
234
|
+
|
|
235
|
+
# Add subdirectories first
|
|
236
|
+
for subdir in subdirs:
|
|
237
|
+
item_idx += 1
|
|
238
|
+
is_last_item = item_idx == total_items
|
|
239
|
+
add_directory(subdir, child_prefix, is_last_item, depth + 1)
|
|
240
|
+
|
|
241
|
+
# Add projects
|
|
242
|
+
for project in projects_here:
|
|
243
|
+
item_idx += 1
|
|
244
|
+
is_last_item = item_idx == total_items
|
|
245
|
+
|
|
246
|
+
connector = "└── " if is_last_item else "├── "
|
|
247
|
+
|
|
248
|
+
# Format markers
|
|
249
|
+
markers = []
|
|
250
|
+
if project.is_git_repo:
|
|
251
|
+
markers.append("git")
|
|
252
|
+
if project.is_manual:
|
|
253
|
+
markers.append("manual")
|
|
254
|
+
marker_str = f" [{','.join(markers)}]" if markers else ""
|
|
255
|
+
|
|
256
|
+
# Just show project name, not full path
|
|
257
|
+
tree_display = f"{child_prefix}{connector}{project.path.name}{marker_str}"
|
|
258
|
+
full_line = f"{project.path}\t\t{tree_display}"
|
|
259
|
+
formatted_lines.append(full_line)
|
|
260
|
+
line_to_project[full_line] = project
|
|
261
|
+
|
|
262
|
+
# Start with base directory
|
|
263
|
+
add_directory(base, "", True, 0)
|
|
264
|
+
|
|
265
|
+
return formatted_lines, line_to_project
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Project selector UI for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess # nosec B404
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from claude_launcher.core.config import ConfigManager
|
|
10
|
+
from claude_launcher.core.discovery import get_all_projects
|
|
11
|
+
from claude_launcher.core.models import Project
|
|
12
|
+
from claude_launcher.core.storage import Storage
|
|
13
|
+
from claude_launcher.ui.preview import build_tree_view
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def clear_screen() -> None:
|
|
17
|
+
"""Clear the terminal screen using ANSI escape codes."""
|
|
18
|
+
print("\033[H\033[2J", end="", flush=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def select_project(
|
|
22
|
+
projects: List[Project],
|
|
23
|
+
storage: Storage,
|
|
24
|
+
show_git_status: bool = True,
|
|
25
|
+
config_manager: Optional[ConfigManager] = None,
|
|
26
|
+
scan_paths: Optional[List[Path]] = None,
|
|
27
|
+
) -> Optional[Project]:
|
|
28
|
+
"""Show interactive project selector with action support.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
projects: List of projects to choose from (already sorted)
|
|
32
|
+
storage: Storage instance for last opened tracking
|
|
33
|
+
show_git_status: Whether to show git status in preview
|
|
34
|
+
config_manager: Config manager for add/remove actions (optional)
|
|
35
|
+
scan_paths: Original scan paths for rescan action (optional)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Selected Project or None if cancelled
|
|
39
|
+
"""
|
|
40
|
+
# Action loop - allows rescanning, adding, removing
|
|
41
|
+
current_projects = projects
|
|
42
|
+
while True:
|
|
43
|
+
if not current_projects:
|
|
44
|
+
print(
|
|
45
|
+
"No projects found. Add scan paths with --setup or add manual paths with --add"
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Clear screen before launching fzf
|
|
50
|
+
clear_screen()
|
|
51
|
+
|
|
52
|
+
# Get default selection index
|
|
53
|
+
# Note: Not currently used - could reorder choices to put default first
|
|
54
|
+
# default_index = storage.get_default_selection_index(current_projects)
|
|
55
|
+
|
|
56
|
+
# Determine base path for display
|
|
57
|
+
# Use the actual scan path for header and tree display
|
|
58
|
+
if scan_paths:
|
|
59
|
+
if len(scan_paths) == 1:
|
|
60
|
+
base_path = scan_paths[0]
|
|
61
|
+
else:
|
|
62
|
+
# Multiple scan paths - find common base
|
|
63
|
+
common = os.path.commonpath([str(p) for p in scan_paths])
|
|
64
|
+
base_path = Path(common)
|
|
65
|
+
else:
|
|
66
|
+
base_path = Path.cwd()
|
|
67
|
+
|
|
68
|
+
# Build tree view of projects with the base path
|
|
69
|
+
# Format: "absolute_path\t\ttree_display"
|
|
70
|
+
choices, choice_to_project = build_tree_view(current_projects, base_path)
|
|
71
|
+
|
|
72
|
+
# Add action menu items at the bottom
|
|
73
|
+
choices.append("__ACTION__\t\t")
|
|
74
|
+
choices.append("__ACTION__\t\t↻ Rescan")
|
|
75
|
+
choices.append("__ACTION__\t\t+ Add path")
|
|
76
|
+
choices.append("__ACTION__\t\t- Remove path")
|
|
77
|
+
|
|
78
|
+
# Build header with project info
|
|
79
|
+
project_count = len(current_projects)
|
|
80
|
+
|
|
81
|
+
header = f"""╭─────────────────────────────────────────╮
|
|
82
|
+
│ Claude Code Launcher │
|
|
83
|
+
╰─────────────────────────────────────────╯
|
|
84
|
+
|
|
85
|
+
{project_count} project{"s" if project_count != 1 else ""} in {base_path}
|
|
86
|
+
|
|
87
|
+
Type to filter, arrows to navigate.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Build preview command using helper script
|
|
91
|
+
helper_script = Path(__file__).parent / "_preview_helper.py"
|
|
92
|
+
preview_cmd = f"{sys.executable} {helper_script} {{}}"
|
|
93
|
+
|
|
94
|
+
# Run fzf directly via subprocess
|
|
95
|
+
try:
|
|
96
|
+
fzf_cmd = [
|
|
97
|
+
"fzf",
|
|
98
|
+
"--prompt=❯ ",
|
|
99
|
+
"--height=100%",
|
|
100
|
+
"--layout=reverse", # Nav at top
|
|
101
|
+
"--border=rounded",
|
|
102
|
+
"--border-label= Projects | Solent Labs™ ",
|
|
103
|
+
"--delimiter=\t\t", # Use double-tab as delimiter
|
|
104
|
+
"--with-nth=2..", # Show only the tree display (field 2 onwards)
|
|
105
|
+
"--preview-window=right:70%:wrap:border-left:nohidden", # Preview 70%, list 30%
|
|
106
|
+
f"--preview={preview_cmd}",
|
|
107
|
+
f"--header={header}",
|
|
108
|
+
"--header-first", # Display header before prompt
|
|
109
|
+
"--info=default", # Show match count on separate line
|
|
110
|
+
"--color=header:italic",
|
|
111
|
+
"--ansi", # Enable ANSI color codes
|
|
112
|
+
"--exact", # Use exact substring matching instead of fuzzy
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Pass choices via stdin
|
|
116
|
+
input_data = "\n".join(choices)
|
|
117
|
+
|
|
118
|
+
# Run fzf with stdin, but let it use the terminal for UI
|
|
119
|
+
# Do NOT capture stdout/stderr - fzf needs direct terminal access
|
|
120
|
+
process = subprocess.Popen( # nosec B603
|
|
121
|
+
fzf_cmd,
|
|
122
|
+
stdin=subprocess.PIPE,
|
|
123
|
+
stdout=subprocess.PIPE,
|
|
124
|
+
text=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
stdout, _ = process.communicate(input=input_data)
|
|
128
|
+
result_code = process.returncode
|
|
129
|
+
|
|
130
|
+
# Check if user cancelled (exit code 130 = Ctrl+C, 1 = no match/cancelled)
|
|
131
|
+
if result_code in (1, 130):
|
|
132
|
+
clear_screen()
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if result_code != 0:
|
|
136
|
+
clear_screen()
|
|
137
|
+
print(f"Error running fzf: exit code {result_code}")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# Get selected line
|
|
141
|
+
selected = stdout.strip()
|
|
142
|
+
if not selected:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Handle action menu items
|
|
146
|
+
if selected == "__ACTION__\t\t↻ Rescan":
|
|
147
|
+
# Rescan - refresh project list
|
|
148
|
+
clear_screen()
|
|
149
|
+
if scan_paths and config_manager:
|
|
150
|
+
config = config_manager.load()
|
|
151
|
+
manual_projects = storage.get_manual_projects()
|
|
152
|
+
current_projects = get_all_projects(
|
|
153
|
+
scan_paths,
|
|
154
|
+
config.scan.max_depth,
|
|
155
|
+
config.scan.prune_dirs,
|
|
156
|
+
manual_projects,
|
|
157
|
+
)
|
|
158
|
+
continue # Loop back to show updated list
|
|
159
|
+
else:
|
|
160
|
+
# Can't rescan without scan_paths
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
elif selected == "__ACTION__\t\t+ Add path":
|
|
164
|
+
# Add manual project
|
|
165
|
+
clear_screen()
|
|
166
|
+
from claude_launcher.ui.browser import browse_directory
|
|
167
|
+
|
|
168
|
+
new_path = browse_directory()
|
|
169
|
+
if new_path:
|
|
170
|
+
storage.add_manual_path(new_path)
|
|
171
|
+
clear_screen()
|
|
172
|
+
print(f"✓ Added: {new_path}\n")
|
|
173
|
+
# Refresh project list
|
|
174
|
+
if scan_paths and config_manager:
|
|
175
|
+
config = config_manager.load()
|
|
176
|
+
manual_projects = storage.get_manual_projects()
|
|
177
|
+
current_projects = get_all_projects(
|
|
178
|
+
scan_paths,
|
|
179
|
+
config.scan.max_depth,
|
|
180
|
+
config.scan.prune_dirs,
|
|
181
|
+
manual_projects,
|
|
182
|
+
)
|
|
183
|
+
continue # Loop back
|
|
184
|
+
|
|
185
|
+
elif selected == "__ACTION__\t\t- Remove path":
|
|
186
|
+
# Remove manual project
|
|
187
|
+
clear_screen()
|
|
188
|
+
from claude_launcher.ui.browser import remove_manual_path
|
|
189
|
+
|
|
190
|
+
removed_path = storage.get_manual_paths() # Get current paths
|
|
191
|
+
if remove_manual_path(storage):
|
|
192
|
+
clear_screen()
|
|
193
|
+
# Show which path was removed by comparing before/after
|
|
194
|
+
after_paths = set(storage.get_manual_paths())
|
|
195
|
+
before_paths = set(removed_path)
|
|
196
|
+
removed = before_paths - after_paths
|
|
197
|
+
if removed:
|
|
198
|
+
print(f"✓ Removed: {removed.pop()}\n")
|
|
199
|
+
# Refresh project list
|
|
200
|
+
if scan_paths and config_manager:
|
|
201
|
+
config = config_manager.load()
|
|
202
|
+
manual_projects = storage.get_manual_projects()
|
|
203
|
+
current_projects = get_all_projects(
|
|
204
|
+
scan_paths,
|
|
205
|
+
config.scan.max_depth,
|
|
206
|
+
config.scan.prune_dirs,
|
|
207
|
+
manual_projects,
|
|
208
|
+
)
|
|
209
|
+
continue # Loop back
|
|
210
|
+
|
|
211
|
+
# Handle empty line action item (just loop back)
|
|
212
|
+
elif selected == "__ACTION__\t\t":
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Regular project selection
|
|
216
|
+
if not selected:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# Look up the project from the formatted line
|
|
220
|
+
project = choice_to_project.get(selected)
|
|
221
|
+
if project:
|
|
222
|
+
# Clear screen before launching Claude
|
|
223
|
+
clear_screen()
|
|
224
|
+
return project
|
|
225
|
+
|
|
226
|
+
# Fallback: try to match by index
|
|
227
|
+
try:
|
|
228
|
+
selected_index = choices.index(selected)
|
|
229
|
+
# Clear screen before launching Claude
|
|
230
|
+
clear_screen()
|
|
231
|
+
return current_projects[selected_index]
|
|
232
|
+
except ValueError:
|
|
233
|
+
clear_screen()
|
|
234
|
+
print(f"Warning: Could not find selected project: {selected}")
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
print("Error: fzf not found. Please install fzf:")
|
|
239
|
+
print(" Ubuntu/Debian: sudo apt install fzf")
|
|
240
|
+
print(" macOS: brew install fzf")
|
|
241
|
+
return None
|
|
242
|
+
except Exception as e:
|
|
243
|
+
print(f"Error in project selector: {e}")
|
|
244
|
+
import traceback
|
|
245
|
+
|
|
246
|
+
traceback.print_exc()
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def show_project_list(projects: List[Project]) -> None:
|
|
251
|
+
"""Show a simple list of all projects.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
projects: List of projects to display
|
|
255
|
+
"""
|
|
256
|
+
if not projects:
|
|
257
|
+
print("No projects found.")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
print(f"\nFound {len(projects)} project(s):\n")
|
|
261
|
+
|
|
262
|
+
for project in projects:
|
|
263
|
+
markers = []
|
|
264
|
+
if project.is_git_repo:
|
|
265
|
+
markers.append("git")
|
|
266
|
+
if project.is_manual:
|
|
267
|
+
markers.append("manual")
|
|
268
|
+
|
|
269
|
+
marker_str = f" [{','.join(markers)}]" if markers else ""
|
|
270
|
+
print(f" {project.path}{marker_str}")
|
|
271
|
+
|
|
272
|
+
print()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions for claude-launcher."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Git utilities for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import subprocess # nosec B404
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from claude_launcher.core.storage import Storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def clone_repository(
|
|
12
|
+
url: str,
|
|
13
|
+
target_base: Path,
|
|
14
|
+
subfolder: Optional[str] = None,
|
|
15
|
+
) -> Path:
|
|
16
|
+
"""Clone a git repository.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
url: Git repository URL (https or ssh)
|
|
20
|
+
target_base: Base directory for cloning
|
|
21
|
+
subfolder: Optional subfolder within target_base
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path to cloned repository
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If URL is invalid or target exists
|
|
28
|
+
RuntimeError: If git clone fails
|
|
29
|
+
"""
|
|
30
|
+
# Validate URL format
|
|
31
|
+
if not (url.startswith("https://") or url.startswith("git@")):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Invalid git URL: {url}\nMust start with 'https://' or 'git@'"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Extract repository name from URL
|
|
37
|
+
repo_name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
|
38
|
+
|
|
39
|
+
# Construct target path
|
|
40
|
+
if subfolder:
|
|
41
|
+
target_path = target_base / subfolder / repo_name
|
|
42
|
+
else:
|
|
43
|
+
target_path = target_base / repo_name
|
|
44
|
+
|
|
45
|
+
# Check if target already exists
|
|
46
|
+
if target_path.exists():
|
|
47
|
+
raise ValueError(f"Directory already exists: {target_path}")
|
|
48
|
+
|
|
49
|
+
# Ensure parent directory exists
|
|
50
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Clone repository
|
|
53
|
+
try:
|
|
54
|
+
print(f"Cloning {url}...")
|
|
55
|
+
print(f"Target: {target_path}\n")
|
|
56
|
+
|
|
57
|
+
subprocess.run( # nosec B603, B607
|
|
58
|
+
["git", "clone", url, str(target_path)],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
check=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
print("Clone successful!")
|
|
65
|
+
return target_path
|
|
66
|
+
|
|
67
|
+
except subprocess.CalledProcessError as e:
|
|
68
|
+
error_msg = e.stderr if e.stderr else str(e)
|
|
69
|
+
raise RuntimeError(f"Git clone failed: {error_msg}")
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
raise RuntimeError("Git command not found. Please install git.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def interactive_clone(storage: "Storage") -> Optional[Path]:
|
|
75
|
+
"""Interactive git clone workflow.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
storage: Storage instance for adding manual paths
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to cloned repository or None if cancelled
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# Get git URL
|
|
85
|
+
print("\n=== Clone Git Repository ===\n")
|
|
86
|
+
url = input("Git repository URL (https:// or git@): ").strip()
|
|
87
|
+
|
|
88
|
+
if not url:
|
|
89
|
+
print("Cancelled.")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# Validate URL
|
|
93
|
+
if not (url.startswith("https://") or url.startswith("git@")):
|
|
94
|
+
print("Error: Invalid URL. Must start with 'https://' or 'git@'")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
# Get target folder
|
|
98
|
+
print("\nSelect target folder:")
|
|
99
|
+
print(" 1. Home directory (~)")
|
|
100
|
+
print(" 2. ~/projects")
|
|
101
|
+
print(" 3. ~/work")
|
|
102
|
+
print(" 4. Custom path")
|
|
103
|
+
|
|
104
|
+
choice = input("\nChoice (1-4): ").strip()
|
|
105
|
+
|
|
106
|
+
if choice == "1":
|
|
107
|
+
target_base = Path.home()
|
|
108
|
+
subfolder = None
|
|
109
|
+
elif choice == "2":
|
|
110
|
+
target_base = Path.home()
|
|
111
|
+
subfolder = "projects"
|
|
112
|
+
elif choice == "3":
|
|
113
|
+
target_base = Path.home()
|
|
114
|
+
subfolder = "work"
|
|
115
|
+
elif choice == "4":
|
|
116
|
+
custom = input("Enter custom path: ").strip()
|
|
117
|
+
if not custom:
|
|
118
|
+
print("Cancelled.")
|
|
119
|
+
return None
|
|
120
|
+
target_base = Path(custom).expanduser().resolve()
|
|
121
|
+
subfolder = None
|
|
122
|
+
else:
|
|
123
|
+
print("Invalid choice.")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# Clone
|
|
127
|
+
try:
|
|
128
|
+
cloned_path = clone_repository(url, target_base, subfolder)
|
|
129
|
+
|
|
130
|
+
# Ask if they want to add as manual path
|
|
131
|
+
print(f"\nCloned to: {cloned_path}")
|
|
132
|
+
add_manual = input("Add to manual paths? (y/n): ").strip().lower()
|
|
133
|
+
|
|
134
|
+
if add_manual == "y":
|
|
135
|
+
storage.add_manual_path(cloned_path)
|
|
136
|
+
print("Added to manual paths.")
|
|
137
|
+
|
|
138
|
+
# Ask if they want to launch Claude
|
|
139
|
+
launch = input("Launch Claude now? (y/n): ").strip().lower()
|
|
140
|
+
|
|
141
|
+
if launch == "y":
|
|
142
|
+
return cloned_path
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
except (ValueError, RuntimeError) as e:
|
|
147
|
+
print(f"\nError: {e}")
|
|
148
|
+
return None
|