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
claude_launcher/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""CLI interface for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess # nosec B404
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from claude_launcher import __version__
|
|
12
|
+
from claude_launcher.core.config import ConfigManager, get_database_path
|
|
13
|
+
from claude_launcher.core.discovery import get_all_projects
|
|
14
|
+
from claude_launcher.core.storage import Storage
|
|
15
|
+
from claude_launcher.ui.browser import browse_directory, remove_manual_path
|
|
16
|
+
from claude_launcher.ui.selector import select_project, show_project_list
|
|
17
|
+
from claude_launcher.utils.git import interactive_clone
|
|
18
|
+
from claude_launcher.utils.logging import setup_logging
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Fast context switching for Claude Code projects",
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def version_callback(value: bool) -> None:
|
|
27
|
+
"""Print version and exit."""
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"claude-launcher {__version__}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def main(
|
|
35
|
+
path: Optional[Path] = typer.Argument(
|
|
36
|
+
None,
|
|
37
|
+
help="Directory to scan for projects (optional)",
|
|
38
|
+
exists=True,
|
|
39
|
+
),
|
|
40
|
+
version: Optional[bool] = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--version",
|
|
43
|
+
"-v",
|
|
44
|
+
help="Show version and exit",
|
|
45
|
+
callback=version_callback,
|
|
46
|
+
is_eager=True,
|
|
47
|
+
),
|
|
48
|
+
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"),
|
|
49
|
+
debug: bool = typer.Option(False, "--debug", help="Enable debug mode"),
|
|
50
|
+
setup: bool = typer.Option(False, "--setup", help="Run first-time setup"),
|
|
51
|
+
add: bool = typer.Option(False, "--add", help="Add a manual project path"),
|
|
52
|
+
remove: bool = typer.Option(False, "--remove", help="Remove a manual path"),
|
|
53
|
+
list_projects: bool = typer.Option(False, "--list", help="List all projects"),
|
|
54
|
+
recent: bool = typer.Option(False, "--recent", help="Jump to last opened project"),
|
|
55
|
+
clone: bool = typer.Option(False, "--clone", help="Clone a git repository"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Launch Claude Code with interactive project selection.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
claude-launcher # Use configured paths
|
|
61
|
+
claude-launcher ~/projects # Scan specific directory
|
|
62
|
+
claude-launcher /home/user/work # Scan absolute path
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Setup logging
|
|
66
|
+
logger = setup_logging(verbose=verbose or debug)
|
|
67
|
+
logger.debug("Claude Launcher starting")
|
|
68
|
+
logger.debug(f"Version: {__version__}")
|
|
69
|
+
|
|
70
|
+
# Initialize config and storage
|
|
71
|
+
config_manager = ConfigManager()
|
|
72
|
+
storage = Storage(get_database_path())
|
|
73
|
+
|
|
74
|
+
logger.debug(f"Config path: {config_manager.config_path}")
|
|
75
|
+
logger.debug(f"Database path: {storage.db_path}")
|
|
76
|
+
|
|
77
|
+
# Handle --setup
|
|
78
|
+
if setup:
|
|
79
|
+
config = config_manager.run_first_time_setup()
|
|
80
|
+
sys.exit(0)
|
|
81
|
+
|
|
82
|
+
# Load config
|
|
83
|
+
config = config_manager.load()
|
|
84
|
+
|
|
85
|
+
# Determine scan paths: CLI argument takes precedence over config
|
|
86
|
+
if path:
|
|
87
|
+
scan_paths = [path.resolve()]
|
|
88
|
+
elif config.scan.paths:
|
|
89
|
+
scan_paths = config.scan.paths
|
|
90
|
+
else:
|
|
91
|
+
print("Error: No directory specified.")
|
|
92
|
+
print("")
|
|
93
|
+
print("Usage:")
|
|
94
|
+
print(" claude-launcher ~/projects # Scan a specific directory")
|
|
95
|
+
print(" claude-launcher --setup # Run setup wizard")
|
|
96
|
+
print(" claude-launcher --help # Show all options")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
# Handle --add
|
|
100
|
+
if add:
|
|
101
|
+
print("Select a directory to add as a manual project path...")
|
|
102
|
+
selected_path = browse_directory()
|
|
103
|
+
if selected_path:
|
|
104
|
+
storage.add_manual_path(selected_path)
|
|
105
|
+
print(f"Added: {selected_path}")
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
# Handle --remove
|
|
109
|
+
if remove:
|
|
110
|
+
remove_manual_path(storage)
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
|
|
113
|
+
# Handle --clone
|
|
114
|
+
if clone:
|
|
115
|
+
cloned_path = interactive_clone(storage)
|
|
116
|
+
if cloned_path:
|
|
117
|
+
print(f"Launching Claude in: {cloned_path}")
|
|
118
|
+
launch_claude(cloned_path, storage)
|
|
119
|
+
sys.exit(0)
|
|
120
|
+
|
|
121
|
+
# Get all projects
|
|
122
|
+
manual_projects = storage.get_manual_projects()
|
|
123
|
+
all_projects = get_all_projects(
|
|
124
|
+
scan_paths,
|
|
125
|
+
config.scan.max_depth,
|
|
126
|
+
config.scan.prune_dirs,
|
|
127
|
+
manual_projects,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Handle --list
|
|
131
|
+
if list_projects:
|
|
132
|
+
show_project_list(all_projects)
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
# Handle --recent
|
|
136
|
+
if recent:
|
|
137
|
+
last_opened = storage.get_last_opened()
|
|
138
|
+
if not last_opened:
|
|
139
|
+
print("No recent project found.")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
project_path = Path(last_opened)
|
|
143
|
+
if not project_path.exists():
|
|
144
|
+
print(f"Last opened project not found: {project_path}")
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
print(f"Launching Claude in: {project_path}")
|
|
148
|
+
launch_claude(project_path, storage)
|
|
149
|
+
sys.exit(0)
|
|
150
|
+
|
|
151
|
+
# Interactive selection
|
|
152
|
+
selected_project = select_project(
|
|
153
|
+
all_projects, storage, config.ui.show_git_status, config_manager, scan_paths
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if selected_project is None:
|
|
157
|
+
print("Claude Launcher: No project selected")
|
|
158
|
+
sys.exit(0)
|
|
159
|
+
|
|
160
|
+
# Launch Claude
|
|
161
|
+
print(f"Launching Claude in: {selected_project.path}")
|
|
162
|
+
launch_claude(selected_project.path, storage)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def launch_claude(project_path: Path, storage: Storage) -> None:
|
|
166
|
+
"""Launch Claude Code in the specified project directory.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
project_path: Path to the project
|
|
170
|
+
storage: Storage instance for tracking
|
|
171
|
+
"""
|
|
172
|
+
# Verify directory exists
|
|
173
|
+
if not project_path.exists():
|
|
174
|
+
print(f"Error: Directory not found: {project_path}")
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
# Record as last opened
|
|
178
|
+
storage.set_last_opened(project_path)
|
|
179
|
+
|
|
180
|
+
# Change to project directory
|
|
181
|
+
os.chdir(project_path)
|
|
182
|
+
|
|
183
|
+
# Launch Claude
|
|
184
|
+
try:
|
|
185
|
+
subprocess.run(["claude"], check=True) # nosec B603, B607
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
print("Error: 'claude' command not found.")
|
|
188
|
+
print("Make sure Claude Code CLI is installed.")
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
except subprocess.CalledProcessError as e:
|
|
191
|
+
print(f"Error launching Claude: {e}")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
except KeyboardInterrupt:
|
|
194
|
+
print("\nInterrupted by user.")
|
|
195
|
+
sys.exit(0)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core functionality for claude-launcher."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Configuration management for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from platformdirs import user_config_path, user_data_path
|
|
8
|
+
|
|
9
|
+
from claude_launcher.utils.paths import expand_path
|
|
10
|
+
|
|
11
|
+
# Handle TOML parsing for different Python versions
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
try:
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
except ImportError:
|
|
18
|
+
tomllib = None
|
|
19
|
+
|
|
20
|
+
import tomli_w
|
|
21
|
+
|
|
22
|
+
from claude_launcher.core.models import ConfigData, HistoryConfig, ScanConfig, UIConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigManager:
|
|
26
|
+
"""Manages configuration loading, saving, and validation."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
29
|
+
"""Initialize configuration manager.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config_path: Optional path to config file. If None, uses platform default.
|
|
33
|
+
"""
|
|
34
|
+
if config_path is None:
|
|
35
|
+
self.config_path = user_config_path("claude-launcher") / "config.toml"
|
|
36
|
+
else:
|
|
37
|
+
self.config_path = config_path
|
|
38
|
+
|
|
39
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
def load(self) -> ConfigData:
|
|
42
|
+
"""Load configuration from file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
ConfigData instance with loaded or default values
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: If tomli/tomllib is not available
|
|
49
|
+
"""
|
|
50
|
+
if not self.config_path.exists():
|
|
51
|
+
return self._get_defaults()
|
|
52
|
+
|
|
53
|
+
if tomllib is None:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"TOML parsing not available. Install tomli: pip install tomli"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with open(self.config_path, "rb") as f:
|
|
60
|
+
data = tomllib.load(f)
|
|
61
|
+
return self._parse_config(data)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"Warning: Error loading config: {e}")
|
|
64
|
+
print("Falling back to defaults")
|
|
65
|
+
return self._get_defaults()
|
|
66
|
+
|
|
67
|
+
def save(self, config: ConfigData) -> None:
|
|
68
|
+
"""Save configuration to file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
config: ConfigData to save
|
|
72
|
+
"""
|
|
73
|
+
data = self._config_to_dict(config)
|
|
74
|
+
|
|
75
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
with open(self.config_path, "wb") as f:
|
|
77
|
+
tomli_w.dump(data, f)
|
|
78
|
+
|
|
79
|
+
def _get_defaults(self) -> ConfigData:
|
|
80
|
+
"""Get default configuration.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
ConfigData with sensible defaults
|
|
84
|
+
"""
|
|
85
|
+
return ConfigData(
|
|
86
|
+
scan=ScanConfig(
|
|
87
|
+
paths=[],
|
|
88
|
+
max_depth=5,
|
|
89
|
+
),
|
|
90
|
+
ui=UIConfig(
|
|
91
|
+
preview_width=70,
|
|
92
|
+
show_git_status=True,
|
|
93
|
+
),
|
|
94
|
+
history=HistoryConfig(
|
|
95
|
+
max_entries=50,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _parse_config(self, data: Dict[str, Any]) -> ConfigData:
|
|
100
|
+
"""Parse TOML data into ConfigData.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
data: Raw TOML dictionary
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Parsed ConfigData instance
|
|
107
|
+
"""
|
|
108
|
+
# Parse scan config
|
|
109
|
+
scan_data = data.get("scan", {})
|
|
110
|
+
scan_paths = [self._expand_path(p) for p in scan_data.get("paths", [])]
|
|
111
|
+
scan = ScanConfig(
|
|
112
|
+
paths=scan_paths,
|
|
113
|
+
max_depth=scan_data.get("max_depth", 5),
|
|
114
|
+
prune_dirs=scan_data.get(
|
|
115
|
+
"prune_dirs",
|
|
116
|
+
[
|
|
117
|
+
"node_modules",
|
|
118
|
+
".cache",
|
|
119
|
+
"venv",
|
|
120
|
+
"__pycache__",
|
|
121
|
+
".git",
|
|
122
|
+
".venv",
|
|
123
|
+
"env",
|
|
124
|
+
"ENV",
|
|
125
|
+
],
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Parse UI config
|
|
130
|
+
ui_data = data.get("ui", {})
|
|
131
|
+
ui = UIConfig(
|
|
132
|
+
preview_width=ui_data.get("preview_width", 70),
|
|
133
|
+
show_git_status=ui_data.get("show_git_status", True),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Parse history config
|
|
137
|
+
history_data = data.get("history", {})
|
|
138
|
+
history = HistoryConfig(
|
|
139
|
+
max_entries=history_data.get("max_entries", 50),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return ConfigData(scan=scan, ui=ui, history=history)
|
|
143
|
+
|
|
144
|
+
def _config_to_dict(self, config: ConfigData) -> Dict[str, Any]:
|
|
145
|
+
"""Convert ConfigData to dictionary for TOML serialization.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
config: ConfigData to convert
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dictionary suitable for TOML serialization
|
|
152
|
+
"""
|
|
153
|
+
return {
|
|
154
|
+
"scan": {
|
|
155
|
+
"paths": [str(p) for p in config.scan.paths],
|
|
156
|
+
"max_depth": config.scan.max_depth,
|
|
157
|
+
"prune_dirs": config.scan.prune_dirs,
|
|
158
|
+
},
|
|
159
|
+
"ui": {
|
|
160
|
+
"preview_width": config.ui.preview_width,
|
|
161
|
+
"show_git_status": config.ui.show_git_status,
|
|
162
|
+
},
|
|
163
|
+
"history": {
|
|
164
|
+
"max_entries": config.history.max_entries,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _expand_path(self, path_str: str) -> Path:
|
|
169
|
+
"""Expand path with ~ and environment variables.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
path_str: Path string to expand
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Expanded Path object
|
|
176
|
+
"""
|
|
177
|
+
return expand_path(path_str)
|
|
178
|
+
|
|
179
|
+
def run_first_time_setup(self) -> ConfigData:
|
|
180
|
+
"""Run interactive first-time setup wizard.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
ConfigData with user-provided values
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
print("=== Claude Launcher First-Time Setup ===\n")
|
|
187
|
+
|
|
188
|
+
# Get scan paths
|
|
189
|
+
paths = []
|
|
190
|
+
print("Enter directories to scan for projects (one per line).")
|
|
191
|
+
print("Common examples: ~/projects, ~/work, ~/code")
|
|
192
|
+
print("Press Enter on empty line when done.\n")
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
path_input = input("Directory path: ").strip()
|
|
196
|
+
if not path_input:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
expanded_path = self._expand_path(path_input)
|
|
201
|
+
if not expanded_path.exists():
|
|
202
|
+
print(f" Warning: {expanded_path} does not exist")
|
|
203
|
+
confirm = input(" Add anyway? (y/n): ").strip().lower()
|
|
204
|
+
if confirm != "y":
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
paths.append(expanded_path)
|
|
208
|
+
print(f" Added: {expanded_path}")
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f" Error: {e}")
|
|
211
|
+
|
|
212
|
+
if not paths:
|
|
213
|
+
print("\nNo paths added. You can add them later with --setup")
|
|
214
|
+
|
|
215
|
+
# Create config
|
|
216
|
+
config = self._get_defaults()
|
|
217
|
+
config.scan.paths = paths
|
|
218
|
+
|
|
219
|
+
# Save config
|
|
220
|
+
self.save(config)
|
|
221
|
+
print(f"\nConfiguration saved to: {self.config_path}")
|
|
222
|
+
|
|
223
|
+
return config
|
|
224
|
+
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
print("\n\nSetup cancelled.")
|
|
227
|
+
import sys
|
|
228
|
+
|
|
229
|
+
sys.exit(0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_data_dir() -> Path:
|
|
233
|
+
"""Get the data directory for claude-launcher.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Path to data directory (for database, cache, etc.)
|
|
237
|
+
"""
|
|
238
|
+
data_dir = Path(user_data_path("claude-launcher"))
|
|
239
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
return data_dir
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_database_path() -> Path:
|
|
244
|
+
"""Get the path to the SQLite database.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Path to database file
|
|
248
|
+
"""
|
|
249
|
+
data_dir = get_data_dir()
|
|
250
|
+
return data_dir / "projects.db"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Project discovery for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from claude_launcher.core.models import Project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scan_for_git_repos(
|
|
11
|
+
scan_paths: List[Path],
|
|
12
|
+
max_depth: int,
|
|
13
|
+
prune_dirs: List[str],
|
|
14
|
+
) -> List[Project]:
|
|
15
|
+
"""Recursively scan directories for git repositories.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
scan_paths: List of base directories to scan
|
|
19
|
+
max_depth: Maximum depth to traverse
|
|
20
|
+
prune_dirs: Directory names to skip during traversal
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of discovered Project instances
|
|
24
|
+
"""
|
|
25
|
+
projects = []
|
|
26
|
+
seen_paths = set()
|
|
27
|
+
|
|
28
|
+
for base_path in scan_paths:
|
|
29
|
+
if not base_path.exists() or not base_path.is_dir():
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
base_str = str(base_path.resolve())
|
|
33
|
+
|
|
34
|
+
for root, dirs, _ in os.walk(base_path, followlinks=False):
|
|
35
|
+
# Calculate current depth
|
|
36
|
+
depth = root[len(base_str) :].count(os.sep)
|
|
37
|
+
|
|
38
|
+
if depth > max_depth:
|
|
39
|
+
dirs[:] = [] # Stop deeper traversal
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Remove pruned directories from traversal
|
|
43
|
+
dirs[:] = [d for d in dirs if d not in prune_dirs]
|
|
44
|
+
|
|
45
|
+
# Check if this is a git repository
|
|
46
|
+
if ".git" in os.listdir(root):
|
|
47
|
+
project_path = Path(root).resolve()
|
|
48
|
+
path_str = str(project_path)
|
|
49
|
+
|
|
50
|
+
# Avoid duplicates
|
|
51
|
+
if path_str not in seen_paths:
|
|
52
|
+
seen_paths.add(path_str)
|
|
53
|
+
projects.append(Project.from_path(project_path, is_manual=False))
|
|
54
|
+
|
|
55
|
+
return projects
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_all_projects(
|
|
59
|
+
scan_paths: List[Path],
|
|
60
|
+
max_depth: int,
|
|
61
|
+
prune_dirs: List[str],
|
|
62
|
+
manual_projects: List[Project],
|
|
63
|
+
) -> List[Project]:
|
|
64
|
+
"""Get all projects (discovered + manual), sorted alphabetically.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
scan_paths: Directories to scan for git repos
|
|
68
|
+
max_depth: Maximum scan depth
|
|
69
|
+
prune_dirs: Directories to skip
|
|
70
|
+
manual_projects: Manually added projects
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Sorted list of all unique projects
|
|
74
|
+
"""
|
|
75
|
+
# Scan for git repositories
|
|
76
|
+
discovered = scan_for_git_repos(scan_paths, max_depth, prune_dirs)
|
|
77
|
+
|
|
78
|
+
# Remove duplicates (prefer manual over discovered)
|
|
79
|
+
seen_paths = set()
|
|
80
|
+
unique_projects = []
|
|
81
|
+
|
|
82
|
+
# Process manual first (they take precedence)
|
|
83
|
+
for project in sorted(manual_projects, key=lambda p: str(p.path)):
|
|
84
|
+
path_str = str(project.path)
|
|
85
|
+
if path_str not in seen_paths:
|
|
86
|
+
seen_paths.add(path_str)
|
|
87
|
+
unique_projects.append(project)
|
|
88
|
+
|
|
89
|
+
# Then add discovered projects
|
|
90
|
+
for project in discovered:
|
|
91
|
+
path_str = str(project.path)
|
|
92
|
+
if path_str not in seen_paths:
|
|
93
|
+
seen_paths.add(path_str)
|
|
94
|
+
unique_projects.append(project)
|
|
95
|
+
|
|
96
|
+
# Sort alphabetically by path
|
|
97
|
+
return sorted(unique_projects, key=lambda p: str(p.path))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Data models for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Project:
|
|
10
|
+
"""Represents a project that can be launched with Claude."""
|
|
11
|
+
|
|
12
|
+
path: Path
|
|
13
|
+
name: str
|
|
14
|
+
parent_path: Path
|
|
15
|
+
is_git_repo: bool
|
|
16
|
+
is_manual: bool
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_path(cls, path: Path, is_manual: bool = False) -> "Project":
|
|
20
|
+
"""Create a Project from a filesystem path.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path: Path to the project directory
|
|
24
|
+
is_manual: Whether this was manually added (vs auto-discovered)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Project instance
|
|
28
|
+
"""
|
|
29
|
+
# For manual projects, don't resolve symlinks so they appear under their symlink location
|
|
30
|
+
# For discovered projects, resolve to avoid duplicates
|
|
31
|
+
if is_manual:
|
|
32
|
+
resolved_path = path
|
|
33
|
+
else:
|
|
34
|
+
resolved_path = path.resolve()
|
|
35
|
+
|
|
36
|
+
is_git = (resolved_path / ".git").exists()
|
|
37
|
+
return cls(
|
|
38
|
+
path=resolved_path,
|
|
39
|
+
name=resolved_path.name,
|
|
40
|
+
parent_path=resolved_path.parent,
|
|
41
|
+
is_git_repo=is_git,
|
|
42
|
+
is_manual=is_manual,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
"""String representation showing path."""
|
|
47
|
+
return str(self.path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ScanConfig:
|
|
52
|
+
"""Configuration for project scanning."""
|
|
53
|
+
|
|
54
|
+
paths: List[Path] = field(default_factory=list)
|
|
55
|
+
max_depth: int = 5
|
|
56
|
+
prune_dirs: List[str] = field(
|
|
57
|
+
default_factory=lambda: [
|
|
58
|
+
"node_modules",
|
|
59
|
+
".cache",
|
|
60
|
+
"venv",
|
|
61
|
+
"__pycache__",
|
|
62
|
+
".git",
|
|
63
|
+
".venv",
|
|
64
|
+
"env",
|
|
65
|
+
"ENV",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class UIConfig:
|
|
72
|
+
"""Configuration for user interface."""
|
|
73
|
+
|
|
74
|
+
preview_width: int = 70
|
|
75
|
+
show_git_status: bool = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class HistoryConfig:
|
|
80
|
+
"""Configuration for history tracking."""
|
|
81
|
+
|
|
82
|
+
max_entries: int = 50
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ConfigData:
|
|
87
|
+
"""Complete configuration for claude-launcher."""
|
|
88
|
+
|
|
89
|
+
scan: ScanConfig = field(default_factory=ScanConfig)
|
|
90
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
91
|
+
history: HistoryConfig = field(default_factory=HistoryConfig)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class PreviewContent:
|
|
96
|
+
"""Content to display in the project preview pane."""
|
|
97
|
+
|
|
98
|
+
claude_md: Optional[str] = None
|
|
99
|
+
git_status: Optional[str] = None
|
|
100
|
+
directory_listing: Optional[str] = None
|
|
101
|
+
error: Optional[str] = None
|
|
102
|
+
|
|
103
|
+
def format(self) -> str:
|
|
104
|
+
"""Format preview content for display.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Formatted string for fzf preview pane
|
|
108
|
+
"""
|
|
109
|
+
lines = []
|
|
110
|
+
|
|
111
|
+
if self.error:
|
|
112
|
+
lines.append(f"ERROR: {self.error}")
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
if self.claude_md:
|
|
116
|
+
lines.append("=== CLAUDE.md ===")
|
|
117
|
+
lines.append(self.claude_md)
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
if self.git_status:
|
|
121
|
+
lines.append("=== Git Status ===")
|
|
122
|
+
lines.append(self.git_status)
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
if self.directory_listing:
|
|
126
|
+
lines.append("=== Directory ===")
|
|
127
|
+
lines.append(self.directory_listing)
|
|
128
|
+
|
|
129
|
+
return "\n".join(lines) if lines else "No preview available"
|