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.

@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "0.1.21"
3
+ __version__ = "0.2.1"
@@ -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 first user message."""
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
- if data.get('type') == 'message' and data.get('role') == 'user':
179
- content = data.get('content', '')
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
- # Get first 60 chars of the message
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
- preview = text.strip().replace('\n', ' ')[:60]
192
- if len(text) > 60:
193
- preview += "..."
194
- return preview
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
- return "No preview available"
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 Ctrl+C to cancel\n")
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
- choices=[str(i) for i in range(1, len(display_sessions) + 1)],
341
- show_choices=False,
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
- ui_console.print("[red]Invalid choice. Please try again.[/red]")
348
- continue
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 (session_info[0], session_info[5]) # Return (session_id, project_path)
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
- result = display_interactive_ui(matching_sessions, keywords, stderr_mode=args.shell, num_matches=args.num_matches)
515
- if result:
516
- session_id, project_path = result
517
- resume_session(session_id, project_path, shell_mode=args.shell)
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.21
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 API adds ~3-5s latency. Future versions may use local models for instant response.
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
- ![fcs.png](docs/fcs.png)
230
+ ![find-claude-session.png](demos/find-claude-session.png)
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
+ ![find-codex-session.png](demos/find-codex-session.png)
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** - Prevents dangerous `git add -A`, unsafe checkouts, and
265
- accidental data loss
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. Add these functions to your shell config (.bashrc/.zshrc):
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` and
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=qRAVB_-ZIkNZrN86fSSuzUt33irrFIhEkjnCus9Eyr0,90
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=TfQWW2zMDJAnfLREt_P23BB6e9Qb-XS22SSEU80K-4Y,23524
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=S3o9WHzK0hytb7f05IoHFoN-azXJxUDeUPOuxPHvZM0,1230
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.21.dist-info/METADATA,sha256=quyxwy3WCzHdg_Xp3wXYeMOQL2FicmC5DarhWJBzRbA,13341
18
- claude_code_tools-0.1.21.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- claude_code_tools-0.1.21.dist-info/entry_points.txt,sha256=AdJXTNrrAbUp0EhSRQuA0IBjFLBUXdqh7nuBYVFEAig,224
20
- claude_code_tools-0.1.21.dist-info/licenses/LICENSE,sha256=BBQdOBLdFB3CEPmb3pqxeOThaFCIdsiLzmDANsCHhoM,1073
21
- claude_code_tools-0.1.21.dist-info/RECORD,,
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** - Uses Claude for natural language understanding
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's API startup adds ~2-3s latency. Future versions may explore faster local models for instant response.
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.