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,3 @@
1
+ """Claude Launcher - Fast context switching for Claude Code projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m claude_launcher."""
2
+
3
+ from claude_launcher.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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"