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.

@@ -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()