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.
@@ -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