claude-code-tools 0.1.8__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.
Potentially problematic release.
This version of claude-code-tools might be problematic. Click here for more details.
- claude_code_tools/__init__.py +3 -0
- claude_code_tools/dotenv_vault.py +268 -0
- claude_code_tools/find_claude_session.py +523 -0
- claude_code_tools/tmux_cli_controller.py +705 -0
- claude_code_tools-0.1.8.dist-info/METADATA +313 -0
- claude_code_tools-0.1.8.dist-info/RECORD +8 -0
- claude_code_tools-0.1.8.dist-info/WHEEL +4 -0
- claude_code_tools-0.1.8.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
find-claude-session: Search Claude Code session files by keywords
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
find-claude-session "keyword1,keyword2,keyword3..." [-g/--global]
|
|
7
|
+
|
|
8
|
+
This tool searches for Claude Code session JSONL files that contain ALL specified keywords,
|
|
9
|
+
and returns matching session IDs in reverse chronological order.
|
|
10
|
+
|
|
11
|
+
With -g/--global flag, searches across all Claude projects, not just the current one.
|
|
12
|
+
|
|
13
|
+
For the directory change to persist, use the shell function:
|
|
14
|
+
fcs() { eval $(find-claude-session --shell "$@"); }
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import List, Set, Tuple, Optional
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.prompt import Prompt, Confirm
|
|
32
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
33
|
+
from rich import box
|
|
34
|
+
RICH_AVAILABLE = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
RICH_AVAILABLE = False
|
|
37
|
+
|
|
38
|
+
console = Console() if RICH_AVAILABLE else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_claude_project_dir() -> Path:
|
|
42
|
+
"""Convert current working directory to Claude project directory path."""
|
|
43
|
+
cwd = os.getcwd()
|
|
44
|
+
|
|
45
|
+
# Replace / with - to match Claude's directory naming convention
|
|
46
|
+
project_path = cwd.replace("/", "-")
|
|
47
|
+
claude_dir = Path.home() / ".claude" / "projects" / project_path
|
|
48
|
+
return claude_dir
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_all_claude_projects() -> List[Tuple[Path, str]]:
|
|
52
|
+
"""Get all Claude project directories with their original paths."""
|
|
53
|
+
projects_dir = Path.home() / ".claude" / "projects"
|
|
54
|
+
|
|
55
|
+
if not projects_dir.exists():
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
projects = []
|
|
59
|
+
for project_dir in projects_dir.iterdir():
|
|
60
|
+
if project_dir.is_dir():
|
|
61
|
+
# Convert back from Claude's naming to original path
|
|
62
|
+
# Claude's pattern: -Users-username-path-to-project
|
|
63
|
+
# where only path separators (/) are replaced with -
|
|
64
|
+
dir_name = project_dir.name
|
|
65
|
+
|
|
66
|
+
# Split by - but need to be smart about it
|
|
67
|
+
# Pattern is like: -Users-pchalasani-Git-project-name
|
|
68
|
+
# We need to identify which hyphens are path separators vs part of names
|
|
69
|
+
|
|
70
|
+
# Most reliable approach: use known path patterns
|
|
71
|
+
if dir_name.startswith("-Users-"):
|
|
72
|
+
# macOS path
|
|
73
|
+
parts = dir_name[1:].split("-")
|
|
74
|
+
# Reconstruct, assuming first few parts are the path
|
|
75
|
+
# Pattern: Users/username/...
|
|
76
|
+
if len(parts) >= 2:
|
|
77
|
+
# Try to reconstruct the path
|
|
78
|
+
# We know it starts with /Users/username
|
|
79
|
+
original_path = "/" + parts[0] + "/" + parts[1]
|
|
80
|
+
|
|
81
|
+
# For the rest, we need to be careful
|
|
82
|
+
# Common patterns: /Users/username/Git/project-name
|
|
83
|
+
remaining = "-".join(parts[2:])
|
|
84
|
+
|
|
85
|
+
# Check for common directories
|
|
86
|
+
if remaining.startswith("Git-"):
|
|
87
|
+
original_path += "/Git/" + remaining[4:]
|
|
88
|
+
elif remaining:
|
|
89
|
+
# Just append the rest as is
|
|
90
|
+
original_path += "/" + remaining
|
|
91
|
+
else:
|
|
92
|
+
original_path = "/" + dir_name[1:].replace("-", "/")
|
|
93
|
+
elif dir_name.startswith("-home-"):
|
|
94
|
+
# Linux path
|
|
95
|
+
original_path = "/" + dir_name[1:].replace("-", "/")
|
|
96
|
+
else:
|
|
97
|
+
# Unknown pattern, best guess
|
|
98
|
+
original_path = "/" + dir_name.replace("-", "/")
|
|
99
|
+
|
|
100
|
+
projects.append((project_dir, original_path))
|
|
101
|
+
|
|
102
|
+
return projects
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_project_name(original_path: str) -> str:
|
|
106
|
+
"""Extract a readable project name from the original path."""
|
|
107
|
+
# Get the last component of the path as the project name
|
|
108
|
+
parts = original_path.rstrip("/").split("/")
|
|
109
|
+
return parts[-1] if parts else "unknown"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool, int]:
|
|
113
|
+
"""
|
|
114
|
+
Check if all keywords are present in the JSONL file and count lines.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
filepath: Path to the JSONL file
|
|
118
|
+
keywords: List of keywords to search for (case-insensitive)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of (matches: bool, line_count: int)
|
|
122
|
+
- matches: True if ALL keywords are found in the file
|
|
123
|
+
- line_count: Total number of lines in the file
|
|
124
|
+
"""
|
|
125
|
+
# Convert keywords to lowercase for case-insensitive search
|
|
126
|
+
keywords_lower = [k.lower() for k in keywords]
|
|
127
|
+
found_keywords = set()
|
|
128
|
+
line_count = 0
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
132
|
+
for line in f:
|
|
133
|
+
line_count += 1
|
|
134
|
+
line_lower = line.lower()
|
|
135
|
+
# Check which keywords are in this line
|
|
136
|
+
for keyword in keywords_lower:
|
|
137
|
+
if keyword in line_lower:
|
|
138
|
+
found_keywords.add(keyword)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Skip files that can't be read
|
|
141
|
+
return False, 0
|
|
142
|
+
|
|
143
|
+
matches = len(found_keywords) == len(keywords_lower)
|
|
144
|
+
return matches, line_count
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_session_preview(filepath: Path) -> str:
|
|
148
|
+
"""Get a preview of the session from the first user message."""
|
|
149
|
+
try:
|
|
150
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
151
|
+
for line in f:
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(line.strip())
|
|
154
|
+
if data.get('type') == 'message' and data.get('role') == 'user':
|
|
155
|
+
content = data.get('content', '')
|
|
156
|
+
if isinstance(content, str):
|
|
157
|
+
# Get first 60 chars of the message
|
|
158
|
+
preview = content.strip().replace('\n', ' ')[:60]
|
|
159
|
+
if len(content) > 60:
|
|
160
|
+
preview += "..."
|
|
161
|
+
return preview
|
|
162
|
+
elif isinstance(content, list):
|
|
163
|
+
# Handle structured content
|
|
164
|
+
for item in content:
|
|
165
|
+
if isinstance(item, dict) and item.get('type') == 'text':
|
|
166
|
+
text = item.get('text', '')
|
|
167
|
+
preview = text.strip().replace('\n', ' ')[:60]
|
|
168
|
+
if len(text) > 60:
|
|
169
|
+
preview += "..."
|
|
170
|
+
return preview
|
|
171
|
+
except (json.JSONDecodeError, KeyError):
|
|
172
|
+
continue
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
return "No preview available"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_sessions(keywords: List[str], global_search: bool = False) -> List[Tuple[str, float, int, str, str, str]]:
|
|
179
|
+
"""
|
|
180
|
+
Find all Claude Code sessions containing the specified keywords.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
keywords: List of keywords to search for
|
|
184
|
+
global_search: If True, search all projects; if False, search current project only
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of tuples (session_id, modification_time, line_count, project_name, preview, project_path) sorted by modification time
|
|
188
|
+
"""
|
|
189
|
+
matching_sessions = []
|
|
190
|
+
|
|
191
|
+
if global_search:
|
|
192
|
+
# Search all projects
|
|
193
|
+
projects = get_all_claude_projects()
|
|
194
|
+
|
|
195
|
+
if RICH_AVAILABLE and console:
|
|
196
|
+
with Progress(
|
|
197
|
+
SpinnerColumn(),
|
|
198
|
+
TextColumn("[progress.description]{task.description}"),
|
|
199
|
+
console=console,
|
|
200
|
+
transient=True
|
|
201
|
+
) as progress:
|
|
202
|
+
task = progress.add_task(f"Searching {len(projects)} projects...", total=len(projects))
|
|
203
|
+
|
|
204
|
+
for project_dir, original_path in projects:
|
|
205
|
+
project_name = extract_project_name(original_path)
|
|
206
|
+
progress.update(task, description=f"Searching {project_name}...")
|
|
207
|
+
|
|
208
|
+
# Search all JSONL files in this project directory
|
|
209
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
210
|
+
matches, line_count = search_keywords_in_file(jsonl_file, keywords)
|
|
211
|
+
if matches:
|
|
212
|
+
session_id = jsonl_file.stem
|
|
213
|
+
mod_time = jsonl_file.stat().st_mtime
|
|
214
|
+
preview = get_session_preview(jsonl_file)
|
|
215
|
+
matching_sessions.append((session_id, mod_time, line_count, project_name, preview, original_path))
|
|
216
|
+
|
|
217
|
+
progress.advance(task)
|
|
218
|
+
else:
|
|
219
|
+
# Fallback without rich
|
|
220
|
+
for project_dir, original_path in projects:
|
|
221
|
+
project_name = extract_project_name(original_path)
|
|
222
|
+
|
|
223
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
224
|
+
matches, line_count = search_keywords_in_file(jsonl_file, keywords)
|
|
225
|
+
if matches:
|
|
226
|
+
session_id = jsonl_file.stem
|
|
227
|
+
mod_time = jsonl_file.stat().st_mtime
|
|
228
|
+
preview = get_session_preview(jsonl_file)
|
|
229
|
+
matching_sessions.append((session_id, mod_time, line_count, project_name, preview, original_path))
|
|
230
|
+
else:
|
|
231
|
+
# Search current project only
|
|
232
|
+
claude_dir = get_claude_project_dir()
|
|
233
|
+
|
|
234
|
+
if not claude_dir.exists():
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
project_name = extract_project_name(os.getcwd())
|
|
238
|
+
|
|
239
|
+
# Search all JSONL files in the directory
|
|
240
|
+
for jsonl_file in claude_dir.glob("*.jsonl"):
|
|
241
|
+
matches, line_count = search_keywords_in_file(jsonl_file, keywords)
|
|
242
|
+
if matches:
|
|
243
|
+
session_id = jsonl_file.stem
|
|
244
|
+
mod_time = jsonl_file.stat().st_mtime
|
|
245
|
+
preview = get_session_preview(jsonl_file)
|
|
246
|
+
matching_sessions.append((session_id, mod_time, line_count, project_name, preview, os.getcwd()))
|
|
247
|
+
|
|
248
|
+
# Sort by modification time (newest first)
|
|
249
|
+
matching_sessions.sort(key=lambda x: x[1], reverse=True)
|
|
250
|
+
|
|
251
|
+
return matching_sessions
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def display_interactive_ui(sessions: List[Tuple[str, float, int, str, str, str]], keywords: List[str], stderr_mode: bool = False, num_matches: int = 10) -> Optional[Tuple[str, str]]:
|
|
255
|
+
"""Display interactive UI for session selection."""
|
|
256
|
+
if not RICH_AVAILABLE:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
# Use stderr console if in stderr mode
|
|
260
|
+
ui_console = Console(file=sys.stderr) if stderr_mode else console
|
|
261
|
+
if not ui_console:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
# Limit to specified number of sessions
|
|
265
|
+
display_sessions = sessions[:num_matches]
|
|
266
|
+
|
|
267
|
+
if not display_sessions:
|
|
268
|
+
ui_console.print("[red]No sessions found[/red]")
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Create table
|
|
272
|
+
table = Table(
|
|
273
|
+
title=f"Sessions matching: {', '.join(keywords)}",
|
|
274
|
+
box=box.ROUNDED,
|
|
275
|
+
show_header=True,
|
|
276
|
+
header_style="bold cyan"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
table.add_column("#", style="bold yellow", width=3)
|
|
280
|
+
table.add_column("Session ID", style="dim")
|
|
281
|
+
table.add_column("Project", style="green")
|
|
282
|
+
table.add_column("Date", style="blue")
|
|
283
|
+
table.add_column("Lines", style="cyan", justify="right")
|
|
284
|
+
table.add_column("Preview", style="white", overflow="fold")
|
|
285
|
+
|
|
286
|
+
for idx, (session_id, mod_time, line_count, project_name, preview, _) in enumerate(display_sessions, 1):
|
|
287
|
+
mod_date = datetime.fromtimestamp(mod_time).strftime('%Y-%m-%d %H:%M')
|
|
288
|
+
table.add_row(
|
|
289
|
+
str(idx),
|
|
290
|
+
session_id[:8] + "...",
|
|
291
|
+
project_name,
|
|
292
|
+
mod_date,
|
|
293
|
+
str(line_count),
|
|
294
|
+
preview
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
ui_console.print(table)
|
|
298
|
+
ui_console.print("\n[bold]Select a session:[/bold]")
|
|
299
|
+
ui_console.print(f" • Enter number (1-{len(display_sessions)}) to select")
|
|
300
|
+
ui_console.print(" • Press Ctrl+C to cancel\n")
|
|
301
|
+
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
# In stderr mode, we need to ensure nothing goes to stdout
|
|
305
|
+
if stderr_mode:
|
|
306
|
+
# Temporarily redirect stdout to devnull
|
|
307
|
+
old_stdout = sys.stdout
|
|
308
|
+
sys.stdout = open(os.devnull, 'w')
|
|
309
|
+
|
|
310
|
+
choice = Prompt.ask(
|
|
311
|
+
"Your choice",
|
|
312
|
+
choices=[str(i) for i in range(1, len(display_sessions) + 1)],
|
|
313
|
+
show_choices=False,
|
|
314
|
+
console=ui_console
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Restore stdout
|
|
318
|
+
if stderr_mode:
|
|
319
|
+
sys.stdout.close()
|
|
320
|
+
sys.stdout = old_stdout
|
|
321
|
+
|
|
322
|
+
idx = int(choice) - 1
|
|
323
|
+
if 0 <= idx < len(display_sessions):
|
|
324
|
+
session_info = display_sessions[idx]
|
|
325
|
+
return (session_info[0], session_info[5]) # Return (session_id, project_path)
|
|
326
|
+
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
ui_console.print("\n[yellow]Cancelled[/yellow]")
|
|
329
|
+
return None
|
|
330
|
+
except (ValueError, EOFError):
|
|
331
|
+
ui_console.print("[red]Invalid choice. Please try again.[/red]")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def resume_session(session_id: str, project_path: str, shell_mode: bool = False):
|
|
335
|
+
"""Resume a Claude session using claude -r command."""
|
|
336
|
+
current_dir = os.getcwd()
|
|
337
|
+
|
|
338
|
+
# In shell mode, output commands for the shell to evaluate
|
|
339
|
+
if shell_mode:
|
|
340
|
+
if project_path != current_dir:
|
|
341
|
+
print(f'cd {shlex.quote(project_path)}')
|
|
342
|
+
print(f'claude -r {shlex.quote(session_id)}')
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# Check if we need to change directory
|
|
346
|
+
change_dir = False
|
|
347
|
+
if project_path != current_dir:
|
|
348
|
+
if RICH_AVAILABLE and console:
|
|
349
|
+
console.print(f"\n[yellow]This session is from a different project:[/yellow]")
|
|
350
|
+
console.print(f" Current directory: {current_dir}")
|
|
351
|
+
console.print(f" Session directory: {project_path}")
|
|
352
|
+
|
|
353
|
+
if Confirm.ask("\nChange to the session's directory?", default=True):
|
|
354
|
+
change_dir = True
|
|
355
|
+
else:
|
|
356
|
+
console.print("[yellow]Staying in current directory. Session resume may fail.[/yellow]")
|
|
357
|
+
else:
|
|
358
|
+
print(f"\nThis session is from a different project:")
|
|
359
|
+
print(f" Current directory: {current_dir}")
|
|
360
|
+
print(f" Session directory: {project_path}")
|
|
361
|
+
|
|
362
|
+
response = input("\nChange to the session's directory? [Y/n]: ").strip().lower()
|
|
363
|
+
if response != 'n':
|
|
364
|
+
change_dir = True
|
|
365
|
+
else:
|
|
366
|
+
print("Staying in current directory. Session resume may fail.")
|
|
367
|
+
|
|
368
|
+
if RICH_AVAILABLE and console:
|
|
369
|
+
console.print(f"\n[green]Resuming session:[/green] {session_id}")
|
|
370
|
+
if change_dir:
|
|
371
|
+
console.print("\n[yellow]Note:[/yellow] To persist directory changes, use this shell function:")
|
|
372
|
+
console.print("[dim]fcs() { eval $(find-claude-session --shell \"$@\"); }[/dim]")
|
|
373
|
+
console.print("Then use [bold]fcs[/bold] instead of [bold]find-claude-session[/bold]\n")
|
|
374
|
+
else:
|
|
375
|
+
print(f"\nResuming session: {session_id}")
|
|
376
|
+
if change_dir:
|
|
377
|
+
print("\nNote: To persist directory changes, use this shell function:")
|
|
378
|
+
print("fcs() { eval $(find-claude-session --shell \"$@\"); }")
|
|
379
|
+
print("Then use 'fcs' instead of 'find-claude-session'\n")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
# Change directory if needed (won't persist after exit)
|
|
383
|
+
if change_dir and project_path != current_dir:
|
|
384
|
+
os.chdir(project_path)
|
|
385
|
+
|
|
386
|
+
# Execute claude
|
|
387
|
+
os.execvp("claude", ["claude", "-r", session_id])
|
|
388
|
+
|
|
389
|
+
except FileNotFoundError:
|
|
390
|
+
if RICH_AVAILABLE and console:
|
|
391
|
+
console.print("[red]Error:[/red] 'claude' command not found. Make sure Claude CLI is installed.")
|
|
392
|
+
else:
|
|
393
|
+
print("Error: 'claude' command not found. Make sure Claude CLI is installed.", file=sys.stderr)
|
|
394
|
+
sys.exit(1)
|
|
395
|
+
except Exception as e:
|
|
396
|
+
if RICH_AVAILABLE and console:
|
|
397
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
398
|
+
else:
|
|
399
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
parser = argparse.ArgumentParser(
|
|
405
|
+
description="Search Claude Code session files by keywords",
|
|
406
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
407
|
+
epilog="""
|
|
408
|
+
Examples:
|
|
409
|
+
find-claude-session "langroid"
|
|
410
|
+
find-claude-session "langroid,MCP"
|
|
411
|
+
find-claude-session "error,TypeError,function" --global
|
|
412
|
+
find-claude-session "bug fix" -g
|
|
413
|
+
|
|
414
|
+
To persist directory changes when resuming sessions:
|
|
415
|
+
Add this to your shell config (.bashrc/.zshrc):
|
|
416
|
+
fcs() { eval $(find-claude-session --shell "$@"); }
|
|
417
|
+
|
|
418
|
+
Then use: fcs "keyword" -g
|
|
419
|
+
"""
|
|
420
|
+
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"keywords",
|
|
423
|
+
help="Comma-separated keywords to search for (case-insensitive)"
|
|
424
|
+
)
|
|
425
|
+
parser.add_argument(
|
|
426
|
+
"-g", "--global",
|
|
427
|
+
action="store_true",
|
|
428
|
+
help="Search across all Claude projects, not just the current one"
|
|
429
|
+
)
|
|
430
|
+
parser.add_argument(
|
|
431
|
+
"-n", "--num-matches",
|
|
432
|
+
type=int,
|
|
433
|
+
default=10,
|
|
434
|
+
help="Number of matching sessions to display (default: 10)"
|
|
435
|
+
)
|
|
436
|
+
parser.add_argument(
|
|
437
|
+
"--shell",
|
|
438
|
+
action="store_true",
|
|
439
|
+
help="Output shell commands for evaluation (for use with shell function)"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
args = parser.parse_args()
|
|
443
|
+
|
|
444
|
+
# Parse keywords
|
|
445
|
+
keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
|
|
446
|
+
|
|
447
|
+
if not keywords:
|
|
448
|
+
print("Error: No keywords provided", file=sys.stderr)
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
|
|
451
|
+
# Check if searching current project only
|
|
452
|
+
if not getattr(args, 'global'):
|
|
453
|
+
claude_dir = get_claude_project_dir()
|
|
454
|
+
|
|
455
|
+
if not claude_dir.exists():
|
|
456
|
+
print(f"No Claude project directory found for: {os.getcwd()}", file=sys.stderr)
|
|
457
|
+
print(f"Expected directory: {claude_dir}", file=sys.stderr)
|
|
458
|
+
sys.exit(1)
|
|
459
|
+
|
|
460
|
+
# Find matching sessions
|
|
461
|
+
matching_sessions = find_sessions(keywords, global_search=getattr(args, 'global'))
|
|
462
|
+
|
|
463
|
+
if not matching_sessions:
|
|
464
|
+
scope = "all projects" if getattr(args, 'global') else "current project"
|
|
465
|
+
if RICH_AVAILABLE and console and not args.shell:
|
|
466
|
+
console.print(f"[yellow]No sessions found containing all keywords in {scope}:[/yellow] {', '.join(keywords)}")
|
|
467
|
+
else:
|
|
468
|
+
print(f"No sessions found containing all keywords in {scope}: {', '.join(keywords)}", file=sys.stderr)
|
|
469
|
+
sys.exit(0)
|
|
470
|
+
|
|
471
|
+
# If we have rich and there are results, show interactive UI
|
|
472
|
+
if RICH_AVAILABLE and console:
|
|
473
|
+
result = display_interactive_ui(matching_sessions, keywords, stderr_mode=args.shell, num_matches=args.num_matches)
|
|
474
|
+
if result:
|
|
475
|
+
session_id, project_path = result
|
|
476
|
+
resume_session(session_id, project_path, shell_mode=args.shell)
|
|
477
|
+
else:
|
|
478
|
+
# Fallback: print session IDs as before
|
|
479
|
+
if not args.shell:
|
|
480
|
+
print("\nMatching sessions:")
|
|
481
|
+
for idx, (session_id, mod_time, line_count, project_name, preview, project_path) in enumerate(matching_sessions[:args.num_matches], 1):
|
|
482
|
+
mod_date = datetime.fromtimestamp(mod_time).strftime('%Y-%m-%d %H:%M:%S')
|
|
483
|
+
if getattr(args, 'global'):
|
|
484
|
+
print(f"{idx}. {session_id} | {project_name} | {mod_date} | {line_count} lines", file=sys.stderr if args.shell else sys.stdout)
|
|
485
|
+
else:
|
|
486
|
+
print(f"{idx}. {session_id} | {mod_date} | {line_count} lines", file=sys.stderr if args.shell else sys.stdout)
|
|
487
|
+
|
|
488
|
+
if len(matching_sessions) > args.num_matches:
|
|
489
|
+
print(f"\n... and {len(matching_sessions) - args.num_matches} more sessions", file=sys.stderr if args.shell else sys.stdout)
|
|
490
|
+
|
|
491
|
+
# Simple selection without rich
|
|
492
|
+
if len(matching_sessions) == 1:
|
|
493
|
+
if not args.shell:
|
|
494
|
+
print("\nOnly one match found. Resuming automatically...")
|
|
495
|
+
session_id, _, _, _, _, project_path = matching_sessions[0]
|
|
496
|
+
resume_session(session_id, project_path, shell_mode=args.shell)
|
|
497
|
+
else:
|
|
498
|
+
try:
|
|
499
|
+
if args.shell:
|
|
500
|
+
# In shell mode, read from stdin but prompt to stderr
|
|
501
|
+
sys.stderr.write("\nEnter number to resume session (or Ctrl+C to cancel): ")
|
|
502
|
+
sys.stderr.flush()
|
|
503
|
+
choice = sys.stdin.readline().strip()
|
|
504
|
+
else:
|
|
505
|
+
choice = input("\nEnter number to resume session (or Ctrl+C to cancel): ")
|
|
506
|
+
|
|
507
|
+
idx = int(choice) - 1
|
|
508
|
+
if 0 <= idx < min(args.num_matches, len(matching_sessions)):
|
|
509
|
+
session_id, _, _, _, _, project_path = matching_sessions[idx]
|
|
510
|
+
resume_session(session_id, project_path, shell_mode=args.shell)
|
|
511
|
+
else:
|
|
512
|
+
print("Invalid choice", file=sys.stderr)
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
except (KeyboardInterrupt, EOFError):
|
|
515
|
+
print("\nCancelled", file=sys.stderr)
|
|
516
|
+
sys.exit(0)
|
|
517
|
+
except ValueError:
|
|
518
|
+
print("Invalid input", file=sys.stderr)
|
|
519
|
+
sys.exit(1)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
main()
|