claude-code-tools 0.1.21__py3-none-any.whl → 0.2.1__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 +1 -1
- claude_code_tools/find_claude_session.py +178 -32
- claude_code_tools/find_codex_session.py +562 -0
- {claude_code_tools-0.1.21.dist-info → claude_code_tools-0.2.1.dist-info}/METADATA +99 -10
- {claude_code_tools-0.1.21.dist-info → claude_code_tools-0.2.1.dist-info}/RECORD +9 -8
- {claude_code_tools-0.1.21.dist-info → claude_code_tools-0.2.1.dist-info}/entry_points.txt +1 -0
- docs/lmsh.md +2 -2
- {claude_code_tools-0.1.21.dist-info → claude_code_tools-0.2.1.dist-info}/WHEEL +0 -0
- {claude_code_tools-0.1.21.dist-info → claude_code_tools-0.2.1.dist-info}/licenses/LICENSE +0 -0
claude_code_tools/__init__.py
CHANGED
|
@@ -168,35 +168,56 @@ def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool,
|
|
|
168
168
|
return matches, line_count, git_branch
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def is_system_message(text: str) -> bool:
|
|
172
|
+
"""Check if text is system-generated (XML tags, env context, etc)"""
|
|
173
|
+
if not text or len(text.strip()) < 5:
|
|
174
|
+
return True
|
|
175
|
+
text = text.strip()
|
|
176
|
+
# Check for XML-like tags (user_instructions, environment_context, etc)
|
|
177
|
+
if text.startswith("<") and ">" in text[:100]:
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
171
182
|
def get_session_preview(filepath: Path) -> str:
|
|
172
|
-
"""Get a preview of the session from the
|
|
183
|
+
"""Get a preview of the session from the LAST user message."""
|
|
184
|
+
last_user_message = None
|
|
185
|
+
|
|
173
186
|
try:
|
|
174
187
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
175
188
|
for line in f:
|
|
176
189
|
try:
|
|
177
190
|
data = json.loads(line.strip())
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
# Check top-level type for user messages
|
|
192
|
+
if data.get('type') == 'user':
|
|
193
|
+
message = data.get('message', {})
|
|
194
|
+
content = message.get('content', '')
|
|
195
|
+
text = None
|
|
196
|
+
|
|
180
197
|
if isinstance(content, str):
|
|
181
|
-
|
|
182
|
-
preview = content.strip().replace('\n', ' ')[:60]
|
|
183
|
-
if len(content) > 60:
|
|
184
|
-
preview += "..."
|
|
185
|
-
return preview
|
|
198
|
+
text = content.strip()
|
|
186
199
|
elif isinstance(content, list):
|
|
187
200
|
# Handle structured content
|
|
188
201
|
for item in content:
|
|
189
202
|
if isinstance(item, dict) and item.get('type') == 'text':
|
|
190
|
-
text = item.get('text', '')
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
203
|
+
text = item.get('text', '').strip()
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
# Filter out system messages and keep updating to get LAST message
|
|
207
|
+
if text and not is_system_message(text):
|
|
208
|
+
cleaned = text.replace('\n', ' ')[:400]
|
|
209
|
+
# Prefer substantial messages (>20 chars)
|
|
210
|
+
if len(cleaned) > 20:
|
|
211
|
+
last_user_message = cleaned
|
|
212
|
+
elif last_user_message is None:
|
|
213
|
+
last_user_message = cleaned
|
|
214
|
+
|
|
195
215
|
except (json.JSONDecodeError, KeyError):
|
|
196
216
|
continue
|
|
197
217
|
except Exception:
|
|
198
218
|
pass
|
|
199
|
-
|
|
219
|
+
|
|
220
|
+
return last_user_message if last_user_message else "No preview available"
|
|
200
221
|
|
|
201
222
|
|
|
202
223
|
def find_sessions(keywords: List[str], global_search: bool = False, claude_home: Optional[str] = None) -> List[Tuple[str, float, int, str, str, str, Optional[str]]]:
|
|
@@ -307,7 +328,7 @@ def display_interactive_ui(sessions: List[Tuple[str, float, int, str, str, str,
|
|
|
307
328
|
table.add_column("Branch", style="magenta")
|
|
308
329
|
table.add_column("Date", style="blue")
|
|
309
330
|
table.add_column("Lines", style="cyan", justify="right")
|
|
310
|
-
table.add_column("Preview", style="white", overflow="fold")
|
|
331
|
+
table.add_column("Preview", style="white", max_width=60, overflow="fold")
|
|
311
332
|
|
|
312
333
|
for idx, (session_id, mod_time, line_count, project_name, preview, _, git_branch) in enumerate(display_sessions, 1):
|
|
313
334
|
mod_date = datetime.fromtimestamp(mod_time).strftime('%Y-%m-%d %H:%M')
|
|
@@ -325,8 +346,8 @@ def display_interactive_ui(sessions: List[Tuple[str, float, int, str, str, str,
|
|
|
325
346
|
ui_console.print(table)
|
|
326
347
|
ui_console.print("\n[bold]Select a session:[/bold]")
|
|
327
348
|
ui_console.print(f" • Enter number (1-{len(display_sessions)}) to select")
|
|
328
|
-
ui_console.print(" • Press
|
|
329
|
-
|
|
349
|
+
ui_console.print(" • Press Enter to cancel\n")
|
|
350
|
+
|
|
330
351
|
while True:
|
|
331
352
|
try:
|
|
332
353
|
# In stderr mode, we need to ensure nothing goes to stdout
|
|
@@ -334,39 +355,148 @@ def display_interactive_ui(sessions: List[Tuple[str, float, int, str, str, str,
|
|
|
334
355
|
# Temporarily redirect stdout to devnull
|
|
335
356
|
old_stdout = sys.stdout
|
|
336
357
|
sys.stdout = open(os.devnull, 'w')
|
|
337
|
-
|
|
358
|
+
|
|
338
359
|
choice = Prompt.ask(
|
|
339
360
|
"Your choice",
|
|
340
|
-
|
|
341
|
-
|
|
361
|
+
default="",
|
|
362
|
+
show_default=False,
|
|
342
363
|
console=ui_console
|
|
343
364
|
)
|
|
344
|
-
|
|
345
|
-
# Handle empty input
|
|
365
|
+
|
|
366
|
+
# Handle empty input - cancel
|
|
346
367
|
if not choice or not choice.strip():
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
368
|
+
# Restore stdout first
|
|
369
|
+
if stderr_mode:
|
|
370
|
+
sys.stdout.close()
|
|
371
|
+
sys.stdout = old_stdout
|
|
372
|
+
ui_console.print("[yellow]Cancelled[/yellow]")
|
|
373
|
+
return None
|
|
374
|
+
|
|
350
375
|
# Restore stdout
|
|
351
376
|
if stderr_mode:
|
|
352
377
|
sys.stdout.close()
|
|
353
378
|
sys.stdout = old_stdout
|
|
354
|
-
|
|
379
|
+
|
|
355
380
|
idx = int(choice) - 1
|
|
356
381
|
if 0 <= idx < len(display_sessions):
|
|
357
382
|
session_info = display_sessions[idx]
|
|
358
|
-
return
|
|
359
|
-
|
|
383
|
+
return session_info # Return full session tuple
|
|
384
|
+
else:
|
|
385
|
+
ui_console.print("[red]Invalid choice. Please try again.[/red]")
|
|
386
|
+
|
|
360
387
|
except KeyboardInterrupt:
|
|
388
|
+
# Restore stdout if needed
|
|
389
|
+
if stderr_mode and sys.stdout != old_stdout:
|
|
390
|
+
sys.stdout.close()
|
|
391
|
+
sys.stdout = old_stdout
|
|
361
392
|
ui_console.print("\n[yellow]Cancelled[/yellow]")
|
|
362
393
|
return None
|
|
363
394
|
except EOFError:
|
|
395
|
+
# Restore stdout if needed
|
|
396
|
+
if stderr_mode and sys.stdout != old_stdout:
|
|
397
|
+
sys.stdout.close()
|
|
398
|
+
sys.stdout = old_stdout
|
|
364
399
|
ui_console.print("\n[yellow]Cancelled (EOF)[/yellow]")
|
|
365
400
|
return None
|
|
366
401
|
except ValueError:
|
|
367
402
|
ui_console.print("[red]Invalid choice. Please try again.[/red]")
|
|
368
403
|
|
|
369
404
|
|
|
405
|
+
def show_action_menu(session_info: Tuple[str, float, int, str, str, str, Optional[str]]) -> Optional[str]:
|
|
406
|
+
"""
|
|
407
|
+
Show action menu for selected session.
|
|
408
|
+
|
|
409
|
+
Returns: action choice ('resume', 'path', 'copy') or None if cancelled
|
|
410
|
+
"""
|
|
411
|
+
session_id, _, _, project_name, _, project_path, git_branch = session_info
|
|
412
|
+
|
|
413
|
+
print(f"\n=== Session: {session_id[:8]}... ===")
|
|
414
|
+
print(f"Project: {project_name}")
|
|
415
|
+
if git_branch:
|
|
416
|
+
print(f"Branch: {git_branch}")
|
|
417
|
+
print(f"\nWhat would you like to do?")
|
|
418
|
+
print("1. Resume session (default)")
|
|
419
|
+
print("2. Show session file path")
|
|
420
|
+
print("3. Copy session file to file (*.jsonl) or directory")
|
|
421
|
+
print()
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
choice = input("Enter choice [1-3] (or Enter for 1): ").strip()
|
|
425
|
+
if not choice or choice == "1":
|
|
426
|
+
return "resume"
|
|
427
|
+
elif choice == "2":
|
|
428
|
+
return "path"
|
|
429
|
+
elif choice == "3":
|
|
430
|
+
return "copy"
|
|
431
|
+
else:
|
|
432
|
+
print("Invalid choice.")
|
|
433
|
+
return None
|
|
434
|
+
except KeyboardInterrupt:
|
|
435
|
+
print("\nCancelled.")
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def get_session_file_path(session_id: str, project_path: str, claude_home: Optional[str] = None) -> str:
|
|
440
|
+
"""Get the full file path for a session."""
|
|
441
|
+
# Convert project path to Claude directory format
|
|
442
|
+
base_dir = Path(claude_home).expanduser() if claude_home else Path.home() / ".claude"
|
|
443
|
+
encoded_path = project_path.replace("/", "-")
|
|
444
|
+
claude_project_dir = base_dir / "projects" / encoded_path
|
|
445
|
+
return str(claude_project_dir / f"{session_id}.jsonl")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def copy_session_file(session_file_path: str) -> None:
|
|
449
|
+
"""Copy session file to user-specified file or directory."""
|
|
450
|
+
try:
|
|
451
|
+
dest = input("\nEnter destination file or directory path: ").strip()
|
|
452
|
+
if not dest:
|
|
453
|
+
print("Cancelled.")
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
dest_path = Path(dest).expanduser()
|
|
457
|
+
source = Path(session_file_path)
|
|
458
|
+
|
|
459
|
+
# Determine if destination is a directory or file
|
|
460
|
+
if dest_path.exists():
|
|
461
|
+
if dest_path.is_dir():
|
|
462
|
+
# Copy into directory with original filename
|
|
463
|
+
dest_file = dest_path / source.name
|
|
464
|
+
else:
|
|
465
|
+
# Copy to specified file
|
|
466
|
+
dest_file = dest_path
|
|
467
|
+
else:
|
|
468
|
+
# Destination doesn't exist - check if it looks like a directory
|
|
469
|
+
if dest.endswith('/') or dest.endswith(os.sep):
|
|
470
|
+
# Treat as directory - create it
|
|
471
|
+
create = input(f"Directory {dest_path} does not exist. Create it? [y/N]: ").strip().lower()
|
|
472
|
+
if create in ('y', 'yes'):
|
|
473
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
dest_file = dest_path / source.name
|
|
475
|
+
else:
|
|
476
|
+
print("Cancelled.")
|
|
477
|
+
return
|
|
478
|
+
else:
|
|
479
|
+
# Treat as file - create parent directory if needed
|
|
480
|
+
parent = dest_path.parent
|
|
481
|
+
if not parent.exists():
|
|
482
|
+
create = input(f"Parent directory {parent} does not exist. Create it? [y/N]: ").strip().lower()
|
|
483
|
+
if create in ('y', 'yes'):
|
|
484
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
else:
|
|
486
|
+
print("Cancelled.")
|
|
487
|
+
return
|
|
488
|
+
dest_file = dest_path
|
|
489
|
+
|
|
490
|
+
import shutil
|
|
491
|
+
shutil.copy2(source, dest_file)
|
|
492
|
+
print(f"\nCopied to: {dest_file}")
|
|
493
|
+
|
|
494
|
+
except KeyboardInterrupt:
|
|
495
|
+
print("\nCancelled.")
|
|
496
|
+
except Exception as e:
|
|
497
|
+
print(f"\nError copying file: {e}")
|
|
498
|
+
|
|
499
|
+
|
|
370
500
|
def resume_session(session_id: str, project_path: str, shell_mode: bool = False):
|
|
371
501
|
"""Resume a Claude session using claude -r command."""
|
|
372
502
|
current_dir = os.getcwd()
|
|
@@ -511,10 +641,26 @@ To persist directory changes when resuming sessions:
|
|
|
511
641
|
|
|
512
642
|
# If we have rich and there are results, show interactive UI
|
|
513
643
|
if RICH_AVAILABLE and console:
|
|
514
|
-
|
|
515
|
-
if
|
|
516
|
-
|
|
517
|
-
|
|
644
|
+
selected_session = display_interactive_ui(matching_sessions, keywords, stderr_mode=args.shell, num_matches=args.num_matches)
|
|
645
|
+
if selected_session:
|
|
646
|
+
# Show action menu
|
|
647
|
+
action = show_action_menu(selected_session)
|
|
648
|
+
if not action:
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
session_id = selected_session[0]
|
|
652
|
+
project_path = selected_session[5]
|
|
653
|
+
|
|
654
|
+
# Perform selected action
|
|
655
|
+
if action == "resume":
|
|
656
|
+
resume_session(session_id, project_path, shell_mode=args.shell)
|
|
657
|
+
elif action == "path":
|
|
658
|
+
session_file_path = get_session_file_path(session_id, project_path, args.claude_home)
|
|
659
|
+
print(f"\nSession file path:")
|
|
660
|
+
print(session_file_path)
|
|
661
|
+
elif action == "copy":
|
|
662
|
+
session_file_path = get_session_file_path(session_id, project_path, args.claude_home)
|
|
663
|
+
copy_session_file(session_file_path)
|
|
518
664
|
else:
|
|
519
665
|
# Fallback: print session IDs as before
|
|
520
666
|
if not args.shell:
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Find and resume Codex sessions by searching keywords in session history.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
find-codex-session "keywords" [OPTIONS]
|
|
7
|
+
fcs-codex "keywords" [OPTIONS] # via shell wrapper
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
find-codex-session "langroid,MCP" # Current project only
|
|
11
|
+
find-codex-session "error,debugging" -g # All projects
|
|
12
|
+
find-codex-session "keywords" -n 5 # Limit results
|
|
13
|
+
fcs-codex "keywords" --shell # Via shell wrapper
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
|
|
31
|
+
RICH_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
RICH_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_codex_home(custom_home: Optional[str] = None) -> Path:
|
|
37
|
+
"""Get the Codex home directory."""
|
|
38
|
+
if custom_home:
|
|
39
|
+
return Path(custom_home).expanduser()
|
|
40
|
+
return Path.home() / ".codex"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_session_id_from_filename(filename: str) -> Optional[str]:
|
|
44
|
+
"""
|
|
45
|
+
Extract session ID from Codex session filename.
|
|
46
|
+
|
|
47
|
+
Format: rollout-YYYY-MM-DDTHH-MM-SS-<SESSION_ID>.jsonl
|
|
48
|
+
Returns: SESSION_ID portion
|
|
49
|
+
"""
|
|
50
|
+
# Pattern: anything after the timestamp part
|
|
51
|
+
# e.g., rollout-2025-10-07T13-48-15-0199bfc9-c444-77e1-8c8a-f91c94fcd832.jsonl
|
|
52
|
+
match = re.match(
|
|
53
|
+
r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl", filename
|
|
54
|
+
)
|
|
55
|
+
if match:
|
|
56
|
+
return match.group(1)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_session_metadata(session_file: Path) -> Optional[dict]:
|
|
61
|
+
"""
|
|
62
|
+
Extract metadata from the first session_meta entry in a Codex session file.
|
|
63
|
+
|
|
64
|
+
Returns dict with: id, cwd, branch, timestamp
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
68
|
+
for line in f:
|
|
69
|
+
if not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
entry = json.loads(line)
|
|
73
|
+
if entry.get("type") == "session_meta":
|
|
74
|
+
payload = entry.get("payload", {})
|
|
75
|
+
git_info = payload.get("git", {})
|
|
76
|
+
return {
|
|
77
|
+
"id": payload.get("id", ""),
|
|
78
|
+
"cwd": payload.get("cwd", ""),
|
|
79
|
+
"branch": git_info.get("branch", ""),
|
|
80
|
+
"timestamp": payload.get("timestamp", ""),
|
|
81
|
+
}
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
continue
|
|
84
|
+
return None
|
|
85
|
+
except (OSError, IOError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_project_name(cwd: str) -> str:
|
|
90
|
+
"""Extract project name from working directory path."""
|
|
91
|
+
if not cwd:
|
|
92
|
+
return "unknown"
|
|
93
|
+
path = Path(cwd)
|
|
94
|
+
return path.name if path.name else "unknown"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_system_message(text: str) -> bool:
|
|
98
|
+
"""Check if text is system-generated (XML tags, env context, etc)"""
|
|
99
|
+
if not text or len(text.strip()) < 5:
|
|
100
|
+
return True
|
|
101
|
+
text = text.strip()
|
|
102
|
+
# Check for XML-like tags (user_instructions, environment_context, etc)
|
|
103
|
+
if text.startswith("<") and ">" in text[:100]:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def search_keywords_in_file(
|
|
109
|
+
session_file: Path, keywords: list[str]
|
|
110
|
+
) -> tuple[bool, int, Optional[str]]:
|
|
111
|
+
"""
|
|
112
|
+
Search for keywords in a Codex session file.
|
|
113
|
+
|
|
114
|
+
Returns: (found, line_count, preview)
|
|
115
|
+
- found: True if all keywords found (case-insensitive AND logic)
|
|
116
|
+
- line_count: total lines in file
|
|
117
|
+
- preview: best user message content (skips system messages)
|
|
118
|
+
"""
|
|
119
|
+
keywords_lower = [k.lower() for k in keywords]
|
|
120
|
+
found_keywords = set()
|
|
121
|
+
line_count = 0
|
|
122
|
+
last_user_message = None # Keep track of the LAST user message
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
126
|
+
for line in f:
|
|
127
|
+
line_count += 1
|
|
128
|
+
if not line.strip():
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
entry = json.loads(line)
|
|
133
|
+
|
|
134
|
+
# Extract user messages (skip system messages)
|
|
135
|
+
# Keep updating to get the LAST one
|
|
136
|
+
if (
|
|
137
|
+
entry.get("type") == "response_item"
|
|
138
|
+
and entry.get("payload", {}).get("role") == "user"
|
|
139
|
+
):
|
|
140
|
+
content = entry.get("payload", {}).get("content", [])
|
|
141
|
+
if isinstance(content, list) and len(content) > 0:
|
|
142
|
+
first_item = content[0]
|
|
143
|
+
if isinstance(first_item, dict):
|
|
144
|
+
text = first_item.get("text", "")
|
|
145
|
+
if text and not is_system_message(text):
|
|
146
|
+
# Keep updating with latest message
|
|
147
|
+
cleaned = text[:400].replace("\n", " ").strip()
|
|
148
|
+
# Only keep if it's substantial (>20 chars)
|
|
149
|
+
if len(cleaned) > 20:
|
|
150
|
+
last_user_message = cleaned
|
|
151
|
+
elif last_user_message is None:
|
|
152
|
+
# Keep even short messages if no better option
|
|
153
|
+
last_user_message = cleaned
|
|
154
|
+
|
|
155
|
+
# Search for keywords in all text content
|
|
156
|
+
line_lower = line.lower()
|
|
157
|
+
for kw in keywords_lower:
|
|
158
|
+
if kw in line_lower:
|
|
159
|
+
found_keywords.add(kw)
|
|
160
|
+
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
all_found = len(found_keywords) == len(keywords_lower)
|
|
165
|
+
return all_found, line_count, last_user_message
|
|
166
|
+
|
|
167
|
+
except (OSError, IOError):
|
|
168
|
+
return False, 0, None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def find_sessions(
|
|
172
|
+
codex_home: Path,
|
|
173
|
+
keywords: list[str],
|
|
174
|
+
num_matches: int = 10,
|
|
175
|
+
global_search: bool = False,
|
|
176
|
+
) -> list[dict]:
|
|
177
|
+
"""
|
|
178
|
+
Find Codex sessions matching keywords.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
codex_home: Path to Codex home directory
|
|
182
|
+
keywords: List of keywords to search for
|
|
183
|
+
num_matches: Maximum number of results to return
|
|
184
|
+
global_search: If False, filter to current directory only
|
|
185
|
+
|
|
186
|
+
Returns list of dicts with: session_id, project, branch, date,
|
|
187
|
+
lines, preview, cwd, file_path
|
|
188
|
+
"""
|
|
189
|
+
sessions_dir = codex_home / "sessions"
|
|
190
|
+
if not sessions_dir.exists():
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
# Get current directory for filtering (if not global search)
|
|
194
|
+
current_cwd = os.getcwd() if not global_search else None
|
|
195
|
+
|
|
196
|
+
matches = []
|
|
197
|
+
|
|
198
|
+
# Walk through YYYY/MM/DD directory structure
|
|
199
|
+
for year_dir in sorted(sessions_dir.iterdir(), reverse=True):
|
|
200
|
+
if not year_dir.is_dir():
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
for month_dir in sorted(year_dir.iterdir(), reverse=True):
|
|
204
|
+
if not month_dir.is_dir():
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
for day_dir in sorted(month_dir.iterdir(), reverse=True):
|
|
208
|
+
if not day_dir.is_dir():
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Process all JSONL files in this day
|
|
212
|
+
session_files = sorted(
|
|
213
|
+
day_dir.glob("rollout-*.jsonl"), reverse=True
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for session_file in session_files:
|
|
217
|
+
# Search for keywords
|
|
218
|
+
found, line_count, preview = search_keywords_in_file(
|
|
219
|
+
session_file, keywords
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not found:
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Extract metadata
|
|
226
|
+
metadata = extract_session_metadata(session_file)
|
|
227
|
+
if not metadata:
|
|
228
|
+
# Fallback: extract session ID from filename
|
|
229
|
+
session_id = extract_session_id_from_filename(
|
|
230
|
+
session_file.name
|
|
231
|
+
)
|
|
232
|
+
if not session_id:
|
|
233
|
+
continue
|
|
234
|
+
metadata = {
|
|
235
|
+
"id": session_id,
|
|
236
|
+
"cwd": "",
|
|
237
|
+
"branch": "",
|
|
238
|
+
"timestamp": "",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Filter by current directory if not global search
|
|
242
|
+
if current_cwd and metadata["cwd"] != current_cwd:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Parse timestamp
|
|
246
|
+
timestamp_str = metadata["timestamp"]
|
|
247
|
+
if timestamp_str:
|
|
248
|
+
try:
|
|
249
|
+
dt = datetime.fromisoformat(
|
|
250
|
+
timestamp_str.replace("Z", "+00:00")
|
|
251
|
+
)
|
|
252
|
+
date_str = dt.strftime("%Y-%m-%d %H:%M")
|
|
253
|
+
except ValueError:
|
|
254
|
+
date_str = timestamp_str[:16]
|
|
255
|
+
else:
|
|
256
|
+
# Fallback to directory date
|
|
257
|
+
date_str = f"{year_dir.name}-{month_dir.name}-{day_dir.name}"
|
|
258
|
+
|
|
259
|
+
matches.append(
|
|
260
|
+
{
|
|
261
|
+
"session_id": metadata["id"],
|
|
262
|
+
"project": get_project_name(metadata["cwd"]),
|
|
263
|
+
"branch": metadata["branch"] or "",
|
|
264
|
+
"date": date_str,
|
|
265
|
+
"lines": line_count,
|
|
266
|
+
"preview": preview or "No preview",
|
|
267
|
+
"cwd": metadata["cwd"],
|
|
268
|
+
"file_path": str(session_file),
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Early exit if we have enough matches
|
|
273
|
+
if len(matches) >= num_matches * 3:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
# Sort by date (reverse chronological) and limit
|
|
277
|
+
matches.sort(key=lambda x: x["date"], reverse=True)
|
|
278
|
+
return matches[:num_matches]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def display_interactive_ui(
|
|
282
|
+
matches: list[dict],
|
|
283
|
+
) -> Optional[dict]:
|
|
284
|
+
"""
|
|
285
|
+
Display matches in interactive UI and get user selection.
|
|
286
|
+
|
|
287
|
+
Returns: selected match dict or None if cancelled
|
|
288
|
+
"""
|
|
289
|
+
if not matches:
|
|
290
|
+
print("No matching sessions found.")
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
if RICH_AVAILABLE:
|
|
294
|
+
console = Console()
|
|
295
|
+
table = Table(title="Codex Sessions", show_header=True)
|
|
296
|
+
table.add_column("#", style="cyan", justify="right")
|
|
297
|
+
table.add_column("Session ID", style="yellow", no_wrap=True)
|
|
298
|
+
table.add_column("Project", style="green")
|
|
299
|
+
table.add_column("Branch", style="magenta")
|
|
300
|
+
table.add_column("Date", style="blue")
|
|
301
|
+
table.add_column("Lines", justify="right")
|
|
302
|
+
table.add_column("Preview", style="dim", max_width=60, overflow="fold")
|
|
303
|
+
|
|
304
|
+
for i, match in enumerate(matches, 1):
|
|
305
|
+
table.add_row(
|
|
306
|
+
str(i),
|
|
307
|
+
match["session_id"][:16] + "...",
|
|
308
|
+
match["project"],
|
|
309
|
+
match["branch"],
|
|
310
|
+
match["date"],
|
|
311
|
+
str(match["lines"]),
|
|
312
|
+
match["preview"], # No truncation, let Rich wrap it
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
console.print(table)
|
|
316
|
+
else:
|
|
317
|
+
# Fallback to plain text
|
|
318
|
+
print("\nMatching Codex Sessions:")
|
|
319
|
+
print("-" * 80)
|
|
320
|
+
for i, match in enumerate(matches, 1):
|
|
321
|
+
print(f"{i}. {match['session_id'][:16]}...")
|
|
322
|
+
print(f" Project: {match['project']}")
|
|
323
|
+
print(f" Branch: {match['branch']}")
|
|
324
|
+
print(f" Date: {match['date']}")
|
|
325
|
+
print(f" Preview: {match['preview'][:60]}...")
|
|
326
|
+
print()
|
|
327
|
+
|
|
328
|
+
# Get user selection
|
|
329
|
+
if len(matches) == 1:
|
|
330
|
+
print(f"\nAuto-selecting only match: {matches[0]['session_id'][:16]}...")
|
|
331
|
+
return matches[0]
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
choice = input(
|
|
335
|
+
"\nEnter number to select session (or Enter to cancel): "
|
|
336
|
+
).strip()
|
|
337
|
+
if not choice:
|
|
338
|
+
print("Cancelled.")
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
idx = int(choice) - 1
|
|
342
|
+
if 0 <= idx < len(matches):
|
|
343
|
+
return matches[idx]
|
|
344
|
+
else:
|
|
345
|
+
print("Invalid selection.")
|
|
346
|
+
return None
|
|
347
|
+
except ValueError:
|
|
348
|
+
print("Invalid input.")
|
|
349
|
+
return None
|
|
350
|
+
except KeyboardInterrupt:
|
|
351
|
+
print("\nCancelled.")
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def show_action_menu(match: dict) -> Optional[str]:
|
|
356
|
+
"""
|
|
357
|
+
Show action menu for selected session.
|
|
358
|
+
|
|
359
|
+
Returns: action choice ('resume', 'path', 'copy') or None if cancelled
|
|
360
|
+
"""
|
|
361
|
+
print(f"\n=== Session: {match['session_id'][:16]}... ===")
|
|
362
|
+
print(f"Project: {match['project']}")
|
|
363
|
+
print(f"Branch: {match['branch']}")
|
|
364
|
+
print(f"\nWhat would you like to do?")
|
|
365
|
+
print("1. Resume session (default)")
|
|
366
|
+
print("2. Show session file path")
|
|
367
|
+
print("3. Copy session file to file (*.jsonl) or directory")
|
|
368
|
+
print()
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
choice = input("Enter choice [1-3] (or Enter for 1): ").strip()
|
|
372
|
+
if not choice or choice == "1":
|
|
373
|
+
return "resume"
|
|
374
|
+
elif choice == "2":
|
|
375
|
+
return "path"
|
|
376
|
+
elif choice == "3":
|
|
377
|
+
return "copy"
|
|
378
|
+
else:
|
|
379
|
+
print("Invalid choice.")
|
|
380
|
+
return None
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
print("\nCancelled.")
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def copy_session_file(file_path: str) -> None:
|
|
387
|
+
"""Copy session file to user-specified file or directory."""
|
|
388
|
+
try:
|
|
389
|
+
dest = input("\nEnter destination file or directory path: ").strip()
|
|
390
|
+
if not dest:
|
|
391
|
+
print("Cancelled.")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
dest_path = Path(dest).expanduser()
|
|
395
|
+
source = Path(file_path)
|
|
396
|
+
|
|
397
|
+
# Determine if destination is a directory or file
|
|
398
|
+
if dest_path.exists():
|
|
399
|
+
if dest_path.is_dir():
|
|
400
|
+
# Copy into directory with original filename
|
|
401
|
+
dest_file = dest_path / source.name
|
|
402
|
+
else:
|
|
403
|
+
# Copy to specified file
|
|
404
|
+
dest_file = dest_path
|
|
405
|
+
else:
|
|
406
|
+
# Destination doesn't exist - check if it looks like a directory
|
|
407
|
+
if dest.endswith('/') or dest.endswith(os.sep):
|
|
408
|
+
# Treat as directory - create it
|
|
409
|
+
create = input(f"Directory {dest_path} does not exist. Create it? [y/N]: ").strip().lower()
|
|
410
|
+
if create in ('y', 'yes'):
|
|
411
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
dest_file = dest_path / source.name
|
|
413
|
+
else:
|
|
414
|
+
print("Cancelled.")
|
|
415
|
+
return
|
|
416
|
+
else:
|
|
417
|
+
# Treat as file - create parent directory if needed
|
|
418
|
+
parent = dest_path.parent
|
|
419
|
+
if not parent.exists():
|
|
420
|
+
create = input(f"Parent directory {parent} does not exist. Create it? [y/N]: ").strip().lower()
|
|
421
|
+
if create in ('y', 'yes'):
|
|
422
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
else:
|
|
424
|
+
print("Cancelled.")
|
|
425
|
+
return
|
|
426
|
+
dest_file = dest_path
|
|
427
|
+
|
|
428
|
+
import shutil
|
|
429
|
+
shutil.copy2(source, dest_file)
|
|
430
|
+
print(f"\nCopied to: {dest_file}")
|
|
431
|
+
|
|
432
|
+
except KeyboardInterrupt:
|
|
433
|
+
print("\nCancelled.")
|
|
434
|
+
except Exception as e:
|
|
435
|
+
print(f"\nError copying file: {e}")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def resume_session(
|
|
439
|
+
session_id: str, cwd: str, shell_mode: bool = False
|
|
440
|
+
) -> None:
|
|
441
|
+
"""
|
|
442
|
+
Resume a Codex session.
|
|
443
|
+
|
|
444
|
+
In shell mode: outputs commands for eval
|
|
445
|
+
In interactive mode: executes codex resume
|
|
446
|
+
"""
|
|
447
|
+
if shell_mode:
|
|
448
|
+
# Output commands for shell eval
|
|
449
|
+
# Redirect prompts to stderr, commands to stdout
|
|
450
|
+
if cwd and cwd != os.getcwd():
|
|
451
|
+
print(f"cd {shlex.quote(cwd)}", file=sys.stdout)
|
|
452
|
+
print(f"codex resume {shlex.quote(session_id)}", file=sys.stdout)
|
|
453
|
+
else:
|
|
454
|
+
# Interactive mode
|
|
455
|
+
if cwd and cwd != os.getcwd():
|
|
456
|
+
response = input(
|
|
457
|
+
f"\nSession is in different directory: {cwd}\n"
|
|
458
|
+
"Change directory and resume? [Y/n]: "
|
|
459
|
+
).strip()
|
|
460
|
+
if response.lower() in ("", "y", "yes"):
|
|
461
|
+
try:
|
|
462
|
+
os.chdir(cwd)
|
|
463
|
+
print(f"Changed to: {cwd}")
|
|
464
|
+
except OSError as e:
|
|
465
|
+
print(f"Error changing directory: {e}")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
# Execute codex resume
|
|
469
|
+
try:
|
|
470
|
+
os.execvp("codex", ["codex", "resume", session_id])
|
|
471
|
+
except OSError as e:
|
|
472
|
+
print(f"Error launching codex: {e}")
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def main():
|
|
477
|
+
"""Main entry point."""
|
|
478
|
+
parser = argparse.ArgumentParser(
|
|
479
|
+
description="Find and resume Codex sessions by keyword search",
|
|
480
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
481
|
+
epilog="""
|
|
482
|
+
Examples:
|
|
483
|
+
find-codex-session "langroid,MCP" # Current project only
|
|
484
|
+
find-codex-session "error,debugging" -g # All projects
|
|
485
|
+
find-codex-session "keywords" -n 5 # Limit results
|
|
486
|
+
fcs-codex "keywords" --shell # Via shell wrapper
|
|
487
|
+
""",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"keywords",
|
|
492
|
+
help="Comma-separated keywords to search (AND logic)",
|
|
493
|
+
)
|
|
494
|
+
parser.add_argument(
|
|
495
|
+
"-g",
|
|
496
|
+
"--global",
|
|
497
|
+
dest="global_search",
|
|
498
|
+
action="store_true",
|
|
499
|
+
help="Search all projects (default: current project only)",
|
|
500
|
+
)
|
|
501
|
+
parser.add_argument(
|
|
502
|
+
"-n",
|
|
503
|
+
"--num-matches",
|
|
504
|
+
type=int,
|
|
505
|
+
default=10,
|
|
506
|
+
help="Number of matches to display (default: 10)",
|
|
507
|
+
)
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"--shell",
|
|
510
|
+
action="store_true",
|
|
511
|
+
help="Output shell commands for eval (enables persistent cd)",
|
|
512
|
+
)
|
|
513
|
+
parser.add_argument(
|
|
514
|
+
"--codex-home",
|
|
515
|
+
help="Custom Codex home directory (default: ~/.codex)",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
args = parser.parse_args()
|
|
519
|
+
|
|
520
|
+
# Parse keywords
|
|
521
|
+
keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
|
|
522
|
+
if not keywords:
|
|
523
|
+
print("Error: No keywords provided", file=sys.stderr)
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
|
|
526
|
+
# Get Codex home
|
|
527
|
+
codex_home = get_codex_home(args.codex_home)
|
|
528
|
+
if not codex_home.exists():
|
|
529
|
+
print(f"Error: Codex home not found: {codex_home}", file=sys.stderr)
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
|
|
532
|
+
# Find matching sessions
|
|
533
|
+
matches = find_sessions(
|
|
534
|
+
codex_home, keywords, args.num_matches, args.global_search
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Display and get selection
|
|
538
|
+
selected_match = display_interactive_ui(matches)
|
|
539
|
+
if not selected_match:
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
# Show action menu
|
|
543
|
+
action = show_action_menu(selected_match)
|
|
544
|
+
if not action:
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Perform selected action
|
|
548
|
+
if action == "resume":
|
|
549
|
+
resume_session(
|
|
550
|
+
selected_match["session_id"],
|
|
551
|
+
selected_match["cwd"],
|
|
552
|
+
args.shell
|
|
553
|
+
)
|
|
554
|
+
elif action == "path":
|
|
555
|
+
print(f"\nSession file path:")
|
|
556
|
+
print(selected_match["file_path"])
|
|
557
|
+
elif action == "copy":
|
|
558
|
+
copy_session_file(selected_match["file_path"])
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-code-tools
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Collection of tools for working with Claude Code
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -17,6 +17,24 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
A collection of practical tools, hooks, and utilities for enhancing Claude Code
|
|
18
18
|
and other CLI coding agents.
|
|
19
19
|
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
- [🎮 tmux-cli: Bridging Claude Code and Interactive CLIs — "playwright for the terminal"](#tmux-cli-bridging-claude-code-and-interactive-clis)
|
|
23
|
+
- [🚀 Quick Start](#quick-start)
|
|
24
|
+
- [🎮 tmux-cli Deep Dive](#tmux-cli-deep-dive)
|
|
25
|
+
- [🚀 lmsh (Experimental) — natural language to shell commands](#lmsh-experimental)
|
|
26
|
+
- [🔍 find-claude-session — search and resume Claude sessions](#find-claude-session)
|
|
27
|
+
- [🔍 find-codex-session — search and resume Codex sessions](#find-codex-session)
|
|
28
|
+
- [🔐 vault — encrypted .env backup & sync](#vault)
|
|
29
|
+
- [🔍 env-safe — inspect .env safely without values](#env-safe)
|
|
30
|
+
- [🛡️ Claude Code Safety Hooks — guardrails for bash, git, env, files](#claude-code-safety-hooks)
|
|
31
|
+
- [🤖 Using Claude Code with Open-weight Anthropic API-compatible LLM Providers](#using-claude-code-with-open-weight-anthropic-api-compatible-llm-providers)
|
|
32
|
+
- [📚 Documentation](#documentation)
|
|
33
|
+
- [📋 Requirements](#requirements)
|
|
34
|
+
- [🛠️ Development](#development)
|
|
35
|
+
- [📄 License](#license)
|
|
36
|
+
|
|
37
|
+
<a id="tmux-cli-bridging-claude-code-and-interactive-clis"></a>
|
|
20
38
|
## 🎮 tmux-cli: Bridging Claude Code and Interactive CLIs
|
|
21
39
|
|
|
22
40
|
> **Note**: While the description below focuses on Claude Code, tmux-cli works with any CLI coding agent.
|
|
@@ -50,6 +68,7 @@ use tmux-cli behind the scenes.
|
|
|
50
68
|
|
|
51
69
|
**Works anywhere**: Automatically handles both local tmux panes and remote sessions.
|
|
52
70
|
|
|
71
|
+
<a id="quick-start"></a>
|
|
53
72
|
## 🚀 Quick Start
|
|
54
73
|
|
|
55
74
|
```bash
|
|
@@ -63,9 +82,11 @@ uv tool install git+https://github.com/pchalasani/claude-code-tools
|
|
|
63
82
|
This gives you:
|
|
64
83
|
- `tmux-cli` - The interactive CLI controller we just covered
|
|
65
84
|
- `find-claude-session` - Search and resume Claude Code sessions by keywords
|
|
85
|
+
- `find-codex-session` - Search and resume Codex sessions by keywords
|
|
66
86
|
- `vault` - Encrypted backup for your .env files
|
|
67
87
|
- `env-safe` - Safely inspect .env files without exposing values
|
|
68
88
|
|
|
89
|
+
<a id="tmux-cli-deep-dive"></a>
|
|
69
90
|
## 🎮 tmux-cli Deep Dive
|
|
70
91
|
|
|
71
92
|
### What Claude Code Can Do With tmux-cli
|
|
@@ -117,6 +138,7 @@ claude mcp add puppeteer -- npx -y @modelcontextprotocol/server-puppeteer
|
|
|
117
138
|
|
|
118
139
|
For detailed instructions, see [docs/tmux-cli-instructions.md](docs/tmux-cli-instructions.md).
|
|
119
140
|
|
|
141
|
+
<a id="lmsh-experimental"></a>
|
|
120
142
|
## 🚀 lmsh (Experimental)
|
|
121
143
|
|
|
122
144
|
Natural language shell - type what you want in plain English, get an editable command.
|
|
@@ -134,11 +156,11 @@ docker ps -n 5 # <-- Edit before running
|
|
|
134
156
|
|
|
135
157
|
**Features:**
|
|
136
158
|
- Rust-based for instant startup (<1ms binary load time)
|
|
137
|
-
- Translates natural language to shell commands using Claude
|
|
159
|
+
- Translates natural language to shell commands using Claude Code CLI
|
|
138
160
|
- Commands are editable before execution - full control
|
|
139
161
|
- Preserves your shell environment
|
|
140
162
|
|
|
141
|
-
**Note:** Claude
|
|
163
|
+
**Note:** Requires Claude Code CLI (`claude` command) to be installed. The translation adds ~2-3s due to Claude Code CLI startup.
|
|
142
164
|
|
|
143
165
|
**Installation:**
|
|
144
166
|
```bash
|
|
@@ -153,6 +175,7 @@ cp target/release/lmsh ~/.cargo/bin/
|
|
|
153
175
|
|
|
154
176
|
See [docs/lmsh.md](docs/lmsh.md) for details.
|
|
155
177
|
|
|
178
|
+
<a id="find-claude-session"></a>
|
|
156
179
|
## 🔍 find-claude-session
|
|
157
180
|
|
|
158
181
|
Search and resume Claude Code sessions by keywords with an interactive UI.
|
|
@@ -202,10 +225,54 @@ won't persist after exiting Claude Code.
|
|
|
202
225
|
|
|
203
226
|
For detailed documentation, see [docs/find-claude-session.md](docs/find-claude-session.md).
|
|
204
227
|
|
|
205
|
-
Looks like this --
|
|
228
|
+
Looks like this --
|
|
206
229
|
|
|
207
|
-

|
|
208
231
|
|
|
232
|
+
<a id="find-codex-session"></a>
|
|
233
|
+
## 🔍 find-codex-session
|
|
234
|
+
|
|
235
|
+
Search and resume Codex sessions by keywords. Usage is similar to `find-claude-session` above, but works with Codex session files instead.
|
|
236
|
+
|
|
237
|
+
### Key Differences from find-claude-session
|
|
238
|
+
|
|
239
|
+
- Searches `~/.codex/sessions/` (organized by YYYY/MM/DD directories)
|
|
240
|
+
- Extracts metadata from `session_meta` entries in Codex JSONL files
|
|
241
|
+
- Resumes sessions with `codex resume <session-id>`
|
|
242
|
+
|
|
243
|
+
### Usage
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Search in current project only (default)
|
|
247
|
+
find-codex-session "keyword1,keyword2"
|
|
248
|
+
|
|
249
|
+
# Search across all projects
|
|
250
|
+
find-codex-session "keywords" -g
|
|
251
|
+
find-codex-session "keywords" --global
|
|
252
|
+
|
|
253
|
+
# Limit number of results
|
|
254
|
+
find-codex-session "keywords" -n 5
|
|
255
|
+
|
|
256
|
+
# Custom Codex home directory
|
|
257
|
+
find-codex-session "keywords" --codex-home /custom/path
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Features
|
|
261
|
+
|
|
262
|
+
- **Project filtering**: Search current project only (default) or all projects with `-g`
|
|
263
|
+
- Case-insensitive AND keyword search across all session content
|
|
264
|
+
- Interactive session selection with Rich table display
|
|
265
|
+
- Shows project name, git branch, date, line count, and preview of last user message
|
|
266
|
+
- Automatic session resumption with `codex resume`
|
|
267
|
+
- Cross-project session support with directory change prompts
|
|
268
|
+
- Reverse chronological ordering (most recent first)
|
|
269
|
+
- Multi-line preview wrapping for better readability
|
|
270
|
+
|
|
271
|
+
Looks like this --
|
|
272
|
+
|
|
273
|
+

|
|
274
|
+
|
|
275
|
+
<a id="vault"></a>
|
|
209
276
|
## 🔐 vault
|
|
210
277
|
|
|
211
278
|
Centralized encrypted backup for .env files across all your projects using SOPS.
|
|
@@ -227,6 +294,7 @@ vault status # Check sync status for current project
|
|
|
227
294
|
|
|
228
295
|
For detailed documentation, see [docs/vault-documentation.md](docs/vault-documentation.md).
|
|
229
296
|
|
|
297
|
+
<a id="env-safe"></a>
|
|
230
298
|
## 🔍 env-safe
|
|
231
299
|
|
|
232
300
|
Safely inspect .env files without exposing sensitive values. Designed for Claude Code and other automated tools that need to work with environment files without accidentally leaking secrets.
|
|
@@ -252,6 +320,7 @@ env-safe --help # See all options
|
|
|
252
320
|
|
|
253
321
|
Claude Code is completely blocked from directly accessing .env files - no reading, writing, or editing allowed. This prevents both accidental exposure of API keys and unintended modifications. The `env-safe` command provides the only approved way for Claude Code to inspect environment configuration safely, while any modifications must be done manually outside of Claude Code.
|
|
254
322
|
|
|
323
|
+
<a id="claude-code-safety-hooks"></a>
|
|
255
324
|
## 🛡️ Claude Code Safety Hooks
|
|
256
325
|
|
|
257
326
|
This repository includes a comprehensive set of safety hooks that enhance Claude
|
|
@@ -261,8 +330,11 @@ Code's behavior and prevent dangerous operations.
|
|
|
261
330
|
|
|
262
331
|
- **File Deletion Protection** - Blocks `rm` commands, enforces TRASH directory
|
|
263
332
|
pattern
|
|
264
|
-
- **Git Safety** -
|
|
265
|
-
|
|
333
|
+
- **Git Safety** - Advanced git add protection with:
|
|
334
|
+
- Hard blocks: `git add .`, `git add ../`, `git add *`, `git add -A/--all`
|
|
335
|
+
- Speed bumps: Shows files before staging directories (e.g., `git add src/`)
|
|
336
|
+
- Commit speed bump: Warns on first attempt, allows on second
|
|
337
|
+
- Prevents unsafe checkouts and accidental data loss
|
|
266
338
|
- **Environment Security** - Blocks all .env file operations (read/write/edit),
|
|
267
339
|
suggests `env-safe` command for safe inspection
|
|
268
340
|
- **Context Management** - Blocks reading files >500 lines to prevent context
|
|
@@ -299,10 +371,12 @@ Code's behavior and prevent dangerous operations.
|
|
|
299
371
|
|
|
300
372
|
For complete documentation, see [hooks/README.md](hooks/README.md).
|
|
301
373
|
|
|
374
|
+
<a id="using-claude-code-with-open-weight-anthropic-api-compatible-llm-providers"></a>
|
|
302
375
|
## 🤖 Using Claude Code with Open-weight Anthropic API-compatible LLM Providers
|
|
303
376
|
|
|
304
377
|
You can use Claude Code with alternative LLMs served via Anthropic-compatible
|
|
305
|
-
APIs.
|
|
378
|
+
APIs, e.g. Kimi-k2, GLM4.5 (from zai), Deepseek-v3.1.
|
|
379
|
+
Add these functions to your shell config (.bashrc/.zshrc):
|
|
306
380
|
|
|
307
381
|
```bash
|
|
308
382
|
kimi() {
|
|
@@ -320,18 +394,30 @@ zai() {
|
|
|
320
394
|
claude "$@"
|
|
321
395
|
)
|
|
322
396
|
}
|
|
397
|
+
|
|
398
|
+
dseek() {
|
|
399
|
+
(
|
|
400
|
+
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
|
401
|
+
export ANTHROPIC_AUTH_TOKEN=${DEEPSEEK_API_KEY}
|
|
402
|
+
export ANTHROPIC_MODEL=deepseek-chat
|
|
403
|
+
export ANTHROPIC_SMALL_FAST_MODEL=deepseek-chat
|
|
404
|
+
claude "$@"
|
|
405
|
+
)
|
|
406
|
+
}
|
|
323
407
|
```
|
|
324
408
|
|
|
325
409
|
After adding these functions:
|
|
326
|
-
- Set your API keys: `export KIMI_API_KEY=your-kimi-key
|
|
327
|
-
`export Z_API_KEY=your-z-key`
|
|
410
|
+
- Set your API keys: `export KIMI_API_KEY=your-kimi-key`,
|
|
411
|
+
`export Z_API_KEY=your-z-key`, `export DEEPSEEK_API_KEY=your-deepseek-key`
|
|
328
412
|
- Run `kimi` to use Claude Code with the Kimi K2 LLM
|
|
329
413
|
- Run `zai` to use Claude Code with the GLM-4.5 model
|
|
414
|
+
- Run `dseek` to use Claude Code with the DeepSeek model
|
|
330
415
|
|
|
331
416
|
The functions use subshells to ensure the environment variables don't affect
|
|
332
417
|
your main shell session, so you could be running multiple instances of Claude Code,
|
|
333
418
|
each using a different LLM.
|
|
334
419
|
|
|
420
|
+
<a id="documentation"></a>
|
|
335
421
|
## 📚 Documentation
|
|
336
422
|
|
|
337
423
|
- [tmux-cli detailed instructions](docs/tmux-cli-instructions.md) -
|
|
@@ -342,6 +428,7 @@ each using a different LLM.
|
|
|
342
428
|
Complete guide for the .env backup system
|
|
343
429
|
- [Hook configuration](hooks/README.md) - Setting up Claude Code hooks
|
|
344
430
|
|
|
431
|
+
<a id="requirements"></a>
|
|
345
432
|
## 📋 Requirements
|
|
346
433
|
|
|
347
434
|
- Python 3.11+
|
|
@@ -349,6 +436,7 @@ each using a different LLM.
|
|
|
349
436
|
- tmux (for tmux-cli functionality)
|
|
350
437
|
- SOPS (for vault functionality)
|
|
351
438
|
|
|
439
|
+
<a id="development"></a>
|
|
352
440
|
## 🛠️ Development
|
|
353
441
|
|
|
354
442
|
### Setup
|
|
@@ -405,6 +493,7 @@ Run `make help` to see all available commands:
|
|
|
405
493
|
- `make release` - Bump patch version and install globally
|
|
406
494
|
- `make patch/minor/major` - Version bump commands
|
|
407
495
|
|
|
496
|
+
<a id="license"></a>
|
|
408
497
|
## 📄 License
|
|
409
498
|
|
|
410
499
|
MIT
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
claude_code_tools/__init__.py,sha256=
|
|
1
|
+
claude_code_tools/__init__.py,sha256=AzSPIlHglKw2AVy9dWwJmnRBYKIN4vLOFYIfPXvjtqE,89
|
|
2
2
|
claude_code_tools/codex_bridge_mcp.py,sha256=0roYm3YgEFB6y2MvGovzHyY7avKtire4qBtz3kVaYoY,12596
|
|
3
3
|
claude_code_tools/dotenv_vault.py,sha256=KPI9NDFu5HE6FfhQUYw6RhdR-miN0ScJHsBg0OVG61k,9617
|
|
4
4
|
claude_code_tools/env_safe.py,sha256=TSSkOjEpzBwNgbeSR-0tR1-pAW_qmbZNmn3fiAsHJ4w,7659
|
|
5
|
-
claude_code_tools/find_claude_session.py,sha256=
|
|
5
|
+
claude_code_tools/find_claude_session.py,sha256=06xGZCG5kGN2IxSKf0oCVKl_CrC8JYbInl8c7OKwUm8,28929
|
|
6
|
+
claude_code_tools/find_codex_session.py,sha256=NvFATapn8aGTXOJmhu8tis9PA12ZpKz7cWYRsZ4vMSQ,19003
|
|
6
7
|
claude_code_tools/tmux_cli_controller.py,sha256=5QDrDlv3oabIghRHuP8jMhUfxPeyYZxizNWW5sVuJIg,34607
|
|
7
8
|
claude_code_tools/tmux_remote_controller.py,sha256=eY1ouLtUzJ40Ik4nqUBvc3Gl1Rx0_L4TFW4j708lgvI,9942
|
|
8
9
|
docs/cc-codex-instructions.md,sha256=5E9QotkrcVYIE5VrvJGi-sg7tdyITDrsbhaqBKr4MUk,1109
|
|
@@ -10,12 +11,12 @@ docs/claude-code-chutes.md,sha256=jCnYAAHZm32NGHE0CzGGl3vpO_zlF_xdmr23YxuCjPg,80
|
|
|
10
11
|
docs/claude-code-tmux-tutorials.md,sha256=S-9U3a1AaPEBPo3oKpWuyOfKK7yPFOIu21P_LDfGUJk,7558
|
|
11
12
|
docs/dot-zshrc.md,sha256=DC2fOiGrUlIzol6N_47CW53a4BsnMEvCnhlRRVxFCTc,7160
|
|
12
13
|
docs/find-claude-session.md,sha256=fACbQP0Bj5jqIpNWk0lGDOQQaji-K9Va3gUv2RA47VQ,4284
|
|
13
|
-
docs/lmsh.md,sha256=
|
|
14
|
+
docs/lmsh.md,sha256=o2TNP1Yfl3zW23GzEqK8Bx6z1hQof_lplaeEucuHNRU,1335
|
|
14
15
|
docs/reddit-post.md,sha256=ZA7kPoJNi06t6F9JQMBiIOv039ADC9lM8YXFt8UA_Jg,2345
|
|
15
16
|
docs/tmux-cli-instructions.md,sha256=hKGOdaPdBlb5XFzHfi0Mm7CVlysBuJUAfop3GHreyuw,5008
|
|
16
17
|
docs/vault-documentation.md,sha256=5XzNpHyhGU38JU2hKEWEL1gdPq3rC2zBg8yotK4eNF4,3600
|
|
17
|
-
claude_code_tools-0.1.
|
|
18
|
-
claude_code_tools-0.1.
|
|
19
|
-
claude_code_tools-0.1.
|
|
20
|
-
claude_code_tools-0.1.
|
|
21
|
-
claude_code_tools-0.1.
|
|
18
|
+
claude_code_tools-0.2.1.dist-info/METADATA,sha256=hRnH4OIKTQL9oTuoG0XEMPn-LPT78EJkaLYiA9fAbqg,17034
|
|
19
|
+
claude_code_tools-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
20
|
+
claude_code_tools-0.2.1.dist-info/entry_points.txt,sha256=rAHzNUN7b_HIRbFlvpYwK38FG6jREYWaO0ssnhAVPrg,287
|
|
21
|
+
claude_code_tools-0.2.1.dist-info/licenses/LICENSE,sha256=BBQdOBLdFB3CEPmb3pqxeOThaFCIdsiLzmDANsCHhoM,1073
|
|
22
|
+
claude_code_tools-0.2.1.dist-info/RECORD,,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[console_scripts]
|
|
2
2
|
env-safe = claude_code_tools.env_safe:main
|
|
3
3
|
find-claude-session = claude_code_tools.find_claude_session:main
|
|
4
|
+
find-codex-session = claude_code_tools.find_codex_session:main
|
|
4
5
|
tmux-cli = claude_code_tools.tmux_cli_controller:main
|
|
5
6
|
vault = claude_code_tools.dotenv_vault:main
|
docs/lmsh.md
CHANGED
|
@@ -40,9 +40,9 @@ lmsh --version # Version info
|
|
|
40
40
|
|
|
41
41
|
- **Editable commands** - Review and modify before execution
|
|
42
42
|
- **Fast startup** - Optimized Rust binary (~1ms)
|
|
43
|
-
- **Claude-powered** -
|
|
43
|
+
- **Claude-powered** - Leverages your existing Claude Code CLI by calling `claude -p <prompt>` in non-interactive mode
|
|
44
44
|
- **Shell preservation** - Maintains your shell environment and aliases
|
|
45
45
|
|
|
46
46
|
## Note
|
|
47
47
|
|
|
48
|
-
Claude
|
|
48
|
+
This tool requires the Claude Code CLI (`claude` command) to be installed and configured. The translation step adds ~2-3s latency due to Claude Code CLI startup time.
|
|
File without changes
|
|
File without changes
|