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,247 @@
1
+ """SQLite storage for claude-launcher."""
2
+
3
+ import shutil
4
+ import sqlite3
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from claude_launcher.core.models import Project
11
+
12
+
13
+ class Storage:
14
+ """Manages SQLite database for manual paths and history."""
15
+
16
+ def __init__(self, db_path: Path):
17
+ """Initialize storage.
18
+
19
+ Args:
20
+ db_path: Path to SQLite database file
21
+ """
22
+ self.db_path = db_path
23
+ self._ensure_database()
24
+
25
+ def _ensure_database(self) -> None:
26
+ """Ensure database exists and has correct schema."""
27
+ try:
28
+ # Try to connect and validate
29
+ conn = sqlite3.connect(self.db_path)
30
+ cursor = conn.cursor()
31
+
32
+ # Check if tables exist
33
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
34
+ tables = {row[0] for row in cursor.fetchall()}
35
+
36
+ if "manual_projects" not in tables or "last_opened" not in tables:
37
+ # Create schema
38
+ self._create_schema(conn)
39
+ else:
40
+ # Validate we can query
41
+ cursor.execute("SELECT 1 FROM manual_projects LIMIT 1")
42
+
43
+ conn.close()
44
+
45
+ except sqlite3.DatabaseError as e:
46
+ print(f"Database corruption detected: {e}")
47
+ self._recover_database()
48
+
49
+ def _create_schema(self, conn: sqlite3.Connection) -> None:
50
+ """Create database schema.
51
+
52
+ Args:
53
+ conn: SQLite connection
54
+ """
55
+ cursor = conn.cursor()
56
+
57
+ # Manual projects table
58
+ cursor.execute(
59
+ """
60
+ CREATE TABLE IF NOT EXISTS manual_projects (
61
+ path TEXT PRIMARY KEY,
62
+ added_at TEXT NOT NULL
63
+ )
64
+ """
65
+ )
66
+
67
+ # Last opened project table (single row)
68
+ cursor.execute(
69
+ """
70
+ CREATE TABLE IF NOT EXISTS last_opened (
71
+ path TEXT PRIMARY KEY
72
+ )
73
+ """
74
+ )
75
+
76
+ # Access history (optional, for future analytics)
77
+ cursor.execute(
78
+ """
79
+ CREATE TABLE IF NOT EXISTS access_history (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ path TEXT NOT NULL,
82
+ accessed_at TEXT NOT NULL
83
+ )
84
+ """
85
+ )
86
+
87
+ cursor.execute(
88
+ """
89
+ CREATE INDEX IF NOT EXISTS idx_access_history_path
90
+ ON access_history(path)
91
+ """
92
+ )
93
+
94
+ cursor.execute(
95
+ """
96
+ CREATE INDEX IF NOT EXISTS idx_access_history_time
97
+ ON access_history(accessed_at)
98
+ """
99
+ )
100
+
101
+ conn.commit()
102
+
103
+ def _recover_database(self) -> None:
104
+ """Recover from corrupted database by backing up and recreating."""
105
+ if self.db_path.exists():
106
+ # Backup corrupted database
107
+ backup_path = self.db_path.with_suffix(f".db.backup.{int(time.time())}")
108
+ shutil.move(str(self.db_path), str(backup_path))
109
+ print(f"Corrupted database backed up to: {backup_path}")
110
+
111
+ # Create fresh database
112
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
113
+ conn = sqlite3.connect(self.db_path)
114
+ self._create_schema(conn)
115
+ conn.close()
116
+ print(f"Fresh database created at: {self.db_path}")
117
+
118
+ def add_manual_path(self, path: Path) -> None:
119
+ """Add a manual project path.
120
+
121
+ Args:
122
+ path: Path to add
123
+ """
124
+ conn = sqlite3.connect(self.db_path)
125
+ cursor = conn.cursor()
126
+
127
+ cursor.execute(
128
+ "INSERT OR REPLACE INTO manual_projects (path, added_at) VALUES (?, ?)",
129
+ (str(path), datetime.now().isoformat()),
130
+ )
131
+
132
+ conn.commit()
133
+ conn.close()
134
+
135
+ def remove_manual_path(self, path: str) -> None:
136
+ """Remove a manual project path.
137
+
138
+ Args:
139
+ path: Path string to remove
140
+ """
141
+ conn = sqlite3.connect(self.db_path)
142
+ cursor = conn.cursor()
143
+
144
+ cursor.execute("DELETE FROM manual_projects WHERE path = ?", (path,))
145
+
146
+ conn.commit()
147
+ conn.close()
148
+
149
+ def get_manual_paths(self) -> List[str]:
150
+ """Get all manual project paths.
151
+
152
+ Returns:
153
+ List of path strings
154
+ """
155
+ conn = sqlite3.connect(self.db_path)
156
+ cursor = conn.cursor()
157
+
158
+ cursor.execute("SELECT path FROM manual_projects ORDER BY path")
159
+ paths = [row[0] for row in cursor.fetchall()]
160
+
161
+ conn.close()
162
+ return paths
163
+
164
+ def get_manual_projects(self) -> List[Project]:
165
+ """Get manual projects, auto-removing non-existent paths.
166
+
167
+ Returns:
168
+ List of valid Project instances
169
+ """
170
+ paths = self.get_manual_paths()
171
+ valid_projects = []
172
+ invalid_paths = []
173
+
174
+ for path_str in paths:
175
+ # Don't resolve symlinks - keep the original path
176
+ # This ensures projects show up in the tree under their symlink location
177
+ path = Path(path_str).expanduser()
178
+
179
+ if path.exists() and path.is_dir():
180
+ project = Project.from_path(path, is_manual=True)
181
+ valid_projects.append(project)
182
+ else:
183
+ invalid_paths.append(path_str)
184
+ print(f"Removing non-existent manual path: {path_str}")
185
+
186
+ # Clean up invalid paths
187
+ for path_str in invalid_paths:
188
+ self.remove_manual_path(path_str)
189
+
190
+ return valid_projects
191
+
192
+ def set_last_opened(self, path: Path) -> None:
193
+ """Set the last opened project.
194
+
195
+ Args:
196
+ path: Path to the project
197
+ """
198
+ conn = sqlite3.connect(self.db_path)
199
+ cursor = conn.cursor()
200
+
201
+ # Clear existing and set new (single row table)
202
+ cursor.execute("DELETE FROM last_opened")
203
+ cursor.execute("INSERT INTO last_opened (path) VALUES (?)", (str(path),))
204
+
205
+ # Also record in access history
206
+ cursor.execute(
207
+ "INSERT INTO access_history (path, accessed_at) VALUES (?, ?)",
208
+ (str(path), datetime.now().isoformat()),
209
+ )
210
+
211
+ conn.commit()
212
+ conn.close()
213
+
214
+ def get_last_opened(self) -> Optional[str]:
215
+ """Get the last opened project path.
216
+
217
+ Returns:
218
+ Path string or None if no history
219
+ """
220
+ conn = sqlite3.connect(self.db_path)
221
+ cursor = conn.cursor()
222
+
223
+ cursor.execute("SELECT path FROM last_opened LIMIT 1")
224
+ row = cursor.fetchone()
225
+
226
+ conn.close()
227
+ result: Optional[str] = row[0] if row else None
228
+ return result
229
+
230
+ def get_default_selection_index(self, projects: List[Project]) -> int:
231
+ """Get the index of the last opened project in the list.
232
+
233
+ Args:
234
+ projects: Sorted list of projects
235
+
236
+ Returns:
237
+ Index of last opened project, or 0 if not found
238
+ """
239
+ last_opened = self.get_last_opened()
240
+ if not last_opened:
241
+ return 0
242
+
243
+ for i, project in enumerate(projects):
244
+ if str(project.path) == last_opened:
245
+ return i
246
+
247
+ return 0
@@ -0,0 +1 @@
1
+ """User interface components for claude-launcher."""
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """Preview helper script for fzf."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from claude_launcher.ui.preview import generate_preview
8
+
9
+
10
+ def main() -> None:
11
+ """Generate preview from fzf selection line."""
12
+ if len(sys.argv) < 2:
13
+ print("No selection provided")
14
+ return
15
+
16
+ line = sys.argv[1]
17
+
18
+ # Extract path from formatted line
19
+ # Format is: "absolute_path\t\ttree_display" or special markers
20
+ # Special marker: __SPACE__ (spacing line)
21
+ parts = line.split("\t\t", 1)
22
+
23
+ if len(parts) == 2 and parts[0] != "__SPACE__":
24
+ # Normal line - first field is absolute path (project or directory)
25
+ path_str = parts[0]
26
+ else:
27
+ # Spacing line or malformed - show nothing
28
+ return
29
+
30
+ try:
31
+ path = Path(path_str).expanduser().resolve()
32
+
33
+ # Show full path at top with divider
34
+ print(f"{path}")
35
+ print("━" * 80)
36
+ print()
37
+
38
+ # Check if it's a directory (folder header) or project
39
+ if path.is_dir() and not (path / ".git").exists():
40
+ # Directory header - show directory contents
41
+ print("Contents:")
42
+ print("─" * 40)
43
+ try:
44
+ # Separate directories and files
45
+ dirs = []
46
+ files = []
47
+ for item in path.iterdir():
48
+ if item.is_dir():
49
+ dirs.append(f"📁 {item.name}/")
50
+ else:
51
+ files.append(f"📄 {item.name}")
52
+
53
+ # Sort each group and combine (folders first)
54
+ items = sorted(dirs) + sorted(files)
55
+
56
+ # Show all items (no limit)
57
+ if items:
58
+ print("\n".join(items))
59
+ else:
60
+ print("(empty directory)")
61
+ except PermissionError:
62
+ print("(permission denied)")
63
+ else:
64
+ # Project directory - show full preview
65
+ preview = generate_preview(path, show_git_status=True)
66
+ print(preview.format())
67
+ except Exception as e:
68
+ print(f"Error generating preview: {e}")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,196 @@
1
+ """Directory browser 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 browse_directory(start_path: Optional[Path] = None) -> Optional[Path]:
12
+ """Interactive directory browser for selecting a path.
13
+
14
+ Args:
15
+ start_path: Starting directory (default: ~/projects or ~)
16
+
17
+ Returns:
18
+ Selected directory Path or None if cancelled
19
+ """
20
+ # Start at ~/projects if it exists, otherwise ~
21
+ if start_path is None:
22
+ projects_dir = Path.home() / "projects"
23
+ start_path = projects_dir if projects_dir.exists() else Path.home()
24
+
25
+ current_path = start_path.resolve()
26
+
27
+ while True:
28
+ # Get directory contents
29
+ try:
30
+ items = []
31
+
32
+ # Add special navigation options
33
+ items.append(".")
34
+ items.append("..")
35
+
36
+ # Add subdirectories
37
+ dirs = []
38
+ for item in sorted(current_path.iterdir()):
39
+ if item.is_dir():
40
+ # Show symlink indicator
41
+ if item.is_symlink():
42
+ dirs.append(item.name + "@")
43
+ else:
44
+ dirs.append(item.name)
45
+
46
+ items.extend(dirs)
47
+
48
+ except PermissionError:
49
+ print(f"Permission denied: {current_path}")
50
+ return None
51
+
52
+ # Build header
53
+ header = f"""╭─────────────────────────────────────────╮
54
+ │ Directory Browser │
55
+ ╰─────────────────────────────────────────╯
56
+ Current: {current_path}
57
+
58
+ . = Select this directory
59
+ .. = Go up one level
60
+ @ suffix = symlink"""
61
+
62
+ # Build preview command
63
+ preview_cmd = f"""
64
+ if [[ {{}} == '.' ]]; then
65
+ echo 'Select: {current_path}'
66
+ elif [[ {{}} == '..' ]]; then
67
+ echo 'Go to: {current_path.parent}'
68
+ else
69
+ target='{current_path}/{{}}'
70
+ target=${{target%@}}
71
+ if [[ -L "$target" ]]; then
72
+ echo "Symlink to: $(readlink -f "$target")"
73
+ echo ''
74
+ fi
75
+ ls -la "$target" 2>/dev/null | head -20
76
+ fi
77
+ """
78
+
79
+ # Run fzf
80
+ try:
81
+ process = subprocess.Popen( # nosec B603, B607
82
+ [
83
+ "fzf",
84
+ "--height=80%",
85
+ "--layout=reverse",
86
+ "--border=rounded",
87
+ f"--header={header}",
88
+ "--header-first",
89
+ "--prompt=> ",
90
+ f"--preview={preview_cmd}",
91
+ "--preview-window=right:60%:wrap",
92
+ ],
93
+ stdin=subprocess.PIPE,
94
+ stdout=subprocess.PIPE,
95
+ text=True,
96
+ )
97
+
98
+ stdout, _ = process.communicate(input="\n".join(items))
99
+
100
+ if process.returncode != 0:
101
+ return None
102
+
103
+ selected = stdout.strip()
104
+ if not selected:
105
+ return None
106
+
107
+ except FileNotFoundError:
108
+ print("Error: fzf not found")
109
+ return None
110
+
111
+ # Handle selection
112
+ if selected == ".":
113
+ # Select current directory
114
+ return current_path
115
+ elif selected == "..":
116
+ # Navigate up
117
+ current_path = current_path.parent
118
+ else:
119
+ # Navigate into subdirectory (strip @ if symlink)
120
+ dir_name = selected.rstrip("@")
121
+ current_path = current_path / dir_name
122
+
123
+
124
+ def remove_manual_path(storage: "Storage") -> bool:
125
+ """Interactive removal of manual paths.
126
+
127
+ Args:
128
+ storage: Storage instance
129
+
130
+ Returns:
131
+ True if a path was removed, False otherwise
132
+ """
133
+
134
+ manual_paths = storage.get_manual_paths()
135
+
136
+ if not manual_paths:
137
+ print("No manual paths to remove.")
138
+ return False
139
+
140
+ # Build header
141
+ count = len(manual_paths)
142
+ header = f"""╭─────────────────────────────────────────╮
143
+ │ Remove Manual Path ({count} total) │
144
+ ╰─────────────────────────────────────────╯
145
+ Select path to remove (Esc to cancel)"""
146
+
147
+ # Build preview command
148
+ preview_cmd = """
149
+ echo 'Will remove:'
150
+ echo ''
151
+ echo ' {}'
152
+ echo ''
153
+ if [[ -d '{}' ]]; then
154
+ echo 'Directory exists'
155
+ else
156
+ echo 'Directory does not exist'
157
+ fi
158
+ """
159
+
160
+ # Run fzf
161
+ try:
162
+ process = subprocess.Popen( # nosec B603, B607
163
+ [
164
+ "fzf",
165
+ "--height=50%",
166
+ "--layout=reverse",
167
+ "--border=rounded",
168
+ f"--header={header}",
169
+ "--header-first",
170
+ "--prompt=Remove > ",
171
+ f"--preview={preview_cmd}",
172
+ "--preview-window=right:60%:wrap",
173
+ ],
174
+ stdin=subprocess.PIPE,
175
+ stdout=subprocess.PIPE,
176
+ text=True,
177
+ )
178
+
179
+ stdout, _ = process.communicate(input="\n".join(manual_paths))
180
+
181
+ if process.returncode != 0:
182
+ print("Cancelled.")
183
+ return False
184
+
185
+ selected = stdout.strip()
186
+ if not selected:
187
+ print("Cancelled.")
188
+ return False
189
+
190
+ except FileNotFoundError:
191
+ print("Error: fzf not found")
192
+ return False
193
+
194
+ # Remove the path
195
+ storage.remove_manual_path(selected)
196
+ return True