claude-code-tools 0.2.4__tar.gz → 0.2.6__tar.gz

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.

Files changed (23) hide show
  1. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/PKG-INFO +98 -2
  2. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/README.md +97 -1
  3. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/__init__.py +1 -1
  4. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/find_claude_session.py +41 -21
  5. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/find_codex_session.py +43 -8
  6. claude_code_tools-0.2.6/claude_code_tools/find_session.py +438 -0
  7. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/pyproject.toml +4 -2
  8. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/.gitignore +0 -0
  9. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/LICENSE +0 -0
  10. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/codex_bridge_mcp.py +0 -0
  11. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/dotenv_vault.py +0 -0
  12. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/env_safe.py +0 -0
  13. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/tmux_cli_controller.py +0 -0
  14. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/claude_code_tools/tmux_remote_controller.py +0 -0
  15. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/cc-codex-instructions.md +0 -0
  16. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/claude-code-chutes.md +0 -0
  17. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/claude-code-tmux-tutorials.md +0 -0
  18. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/dot-zshrc.md +0 -0
  19. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/find-claude-session.md +0 -0
  20. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/lmsh.md +0 -0
  21. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/reddit-post.md +0 -0
  22. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/tmux-cli-instructions.md +0 -0
  23. {claude_code_tools-0.2.4 → claude_code_tools-0.2.6}/docs/vault-documentation.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -23,6 +23,7 @@ and other CLI coding agents.
23
23
  - [🚀 Quick Start](#quick-start)
24
24
  - [🎮 tmux-cli Deep Dive](#tmux-cli-deep-dive)
25
25
  - [🚀 lmsh (Experimental) — natural language to shell commands](#lmsh-experimental)
26
+ - [🔍 find-session — unified search across Claude & Codex sessions](#find-session)
26
27
  - [🔍 find-claude-session — search and resume Claude sessions](#find-claude-session)
27
28
  - [🔍 find-codex-session — search and resume Codex sessions](#find-codex-session)
28
29
  - [🔐 vault — encrypted .env backup & sync](#vault)
@@ -81,6 +82,7 @@ uv tool install git+https://github.com/pchalasani/claude-code-tools
81
82
 
82
83
  This gives you:
83
84
  - `tmux-cli` - The interactive CLI controller we just covered
85
+ - `find-session` - Unified search across Claude Code and Codex sessions
84
86
  - `find-claude-session` - Search and resume Claude Code sessions by keywords
85
87
  - `find-codex-session` - Search and resume Codex sessions by keywords
86
88
  - `vault` - Encrypted backup for your .env files
@@ -175,6 +177,86 @@ cp target/release/lmsh ~/.cargo/bin/
175
177
 
176
178
  See [docs/lmsh.md](docs/lmsh.md) for details.
177
179
 
180
+ <a id="find-session"></a>
181
+ ## 🔍 find-session
182
+
183
+ **Unified session finder** - Search across both Claude Code and Codex sessions simultaneously.
184
+
185
+ > **⚠️ Note about the `fs` command**: For convenience, this tool is available as both `find-session` and `fs`. The short `fs` alias may conflict with existing commands or aliases in your shell. If you have a conflict, you can:
186
+ > - Use the full `find-session` command instead
187
+ > - Create your own alias: `alias mysearch='find-session'`
188
+ > - Uninstall and reinstall without the `fs` entry point (edit `pyproject.toml`)
189
+
190
+ ### Usage
191
+
192
+ ```bash
193
+ # Search all agents in current project
194
+ fs "keyword1,keyword2"
195
+
196
+ # Show all sessions across all agents in current project
197
+ fs
198
+
199
+ # Search across all projects (Claude + Codex)
200
+ fs "keywords" -g
201
+
202
+ # Show all sessions across all projects
203
+ fs -g
204
+
205
+ # Search only specific agent(s)
206
+ fs "bug,fix" --agents claude
207
+ fs "error" --agents codex
208
+
209
+ # Limit number of results
210
+ fs "keywords" -n 15
211
+ ```
212
+
213
+ ### Features
214
+
215
+ - **Multi-agent search**: Searches both Claude Code and Codex sessions simultaneously
216
+ - **Unified display**: Single table showing sessions from all agents with agent column
217
+ - **Smart resume**: Automatically uses correct CLI tool (`claude` or `codex`) based on selected session
218
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
219
+ - **Action menu** after session selection:
220
+ - Resume session (default)
221
+ - Show session file path
222
+ - Copy session file to file (*.jsonl) or directory
223
+ - **Project filtering**: Search current project only (default) or all projects with `-g`
224
+ - **Agent filtering**: Use `--agents claude codex` to search specific agents only
225
+ - **Configurable**: Optional config file at `~/.config/find-session/config.json` for customizing agents
226
+ - Interactive session selection with Rich table display
227
+ - Shows agent, project, git branch, date, line count, and preview
228
+ - Reverse chronological ordering (most recent first)
229
+ - Press Enter to cancel (no need for Ctrl+C)
230
+
231
+ ### Configuration (Optional)
232
+
233
+ Create `~/.config/find-session/config.json` to customize agent settings:
234
+
235
+ ```json
236
+ {
237
+ "agents": [
238
+ {
239
+ "name": "claude",
240
+ "display_name": "Claude",
241
+ "home_dir": "~/.claude",
242
+ "enabled": true
243
+ },
244
+ {
245
+ "name": "codex",
246
+ "display_name": "Codex",
247
+ "home_dir": "~/.codex",
248
+ "enabled": true
249
+ }
250
+ ]
251
+ }
252
+ ```
253
+
254
+ This allows you to:
255
+ - Enable/disable specific agents
256
+ - Override default home directories
257
+ - Customize display names
258
+ - Prepare for future agent additions
259
+
178
260
  <a id="find-claude-session"></a>
179
261
  ## 🔍 find-claude-session
180
262
 
@@ -208,13 +290,20 @@ source /path/to/claude-code-tools/scripts/fcs-function.sh
208
290
  # Search in current project
209
291
  fcs "keyword1,keyword2,keyword3"
210
292
 
211
- # Search across all Claude projects
293
+ # Show all sessions in current project (no keyword filtering)
294
+ fcs
295
+
296
+ # Search across all Claude projects
212
297
  fcs "keywords" --global
213
298
  fcs "keywords" -g
299
+
300
+ # Show all sessions across all projects
301
+ fcs -g
214
302
  ```
215
303
 
216
304
  ### Features
217
305
 
306
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
218
307
  - **Action menu** after session selection:
219
308
  - Resume session (default)
220
309
  - Show session file path
@@ -252,10 +341,16 @@ Search and resume Codex sessions by keywords. Usage is similar to `find-claude-s
252
341
  # Search in current project only (default)
253
342
  find-codex-session "keyword1,keyword2"
254
343
 
344
+ # Show all sessions in current project (no keyword filtering)
345
+ find-codex-session
346
+
255
347
  # Search across all projects
256
348
  find-codex-session "keywords" -g
257
349
  find-codex-session "keywords" --global
258
350
 
351
+ # Show all sessions across all projects
352
+ find-codex-session -g
353
+
259
354
  # Limit number of results
260
355
  find-codex-session "keywords" -n 5
261
356
 
@@ -265,6 +360,7 @@ find-codex-session "keywords" --codex-home /custom/path
265
360
 
266
361
  ### Features
267
362
 
363
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
268
364
  - **Action menu** after session selection:
269
365
  - Resume session (default)
270
366
  - Show session file path
@@ -9,6 +9,7 @@ and other CLI coding agents.
9
9
  - [🚀 Quick Start](#quick-start)
10
10
  - [🎮 tmux-cli Deep Dive](#tmux-cli-deep-dive)
11
11
  - [🚀 lmsh (Experimental) — natural language to shell commands](#lmsh-experimental)
12
+ - [🔍 find-session — unified search across Claude & Codex sessions](#find-session)
12
13
  - [🔍 find-claude-session — search and resume Claude sessions](#find-claude-session)
13
14
  - [🔍 find-codex-session — search and resume Codex sessions](#find-codex-session)
14
15
  - [🔐 vault — encrypted .env backup & sync](#vault)
@@ -67,6 +68,7 @@ uv tool install git+https://github.com/pchalasani/claude-code-tools
67
68
 
68
69
  This gives you:
69
70
  - `tmux-cli` - The interactive CLI controller we just covered
71
+ - `find-session` - Unified search across Claude Code and Codex sessions
70
72
  - `find-claude-session` - Search and resume Claude Code sessions by keywords
71
73
  - `find-codex-session` - Search and resume Codex sessions by keywords
72
74
  - `vault` - Encrypted backup for your .env files
@@ -161,6 +163,86 @@ cp target/release/lmsh ~/.cargo/bin/
161
163
 
162
164
  See [docs/lmsh.md](docs/lmsh.md) for details.
163
165
 
166
+ <a id="find-session"></a>
167
+ ## 🔍 find-session
168
+
169
+ **Unified session finder** - Search across both Claude Code and Codex sessions simultaneously.
170
+
171
+ > **⚠️ Note about the `fs` command**: For convenience, this tool is available as both `find-session` and `fs`. The short `fs` alias may conflict with existing commands or aliases in your shell. If you have a conflict, you can:
172
+ > - Use the full `find-session` command instead
173
+ > - Create your own alias: `alias mysearch='find-session'`
174
+ > - Uninstall and reinstall without the `fs` entry point (edit `pyproject.toml`)
175
+
176
+ ### Usage
177
+
178
+ ```bash
179
+ # Search all agents in current project
180
+ fs "keyword1,keyword2"
181
+
182
+ # Show all sessions across all agents in current project
183
+ fs
184
+
185
+ # Search across all projects (Claude + Codex)
186
+ fs "keywords" -g
187
+
188
+ # Show all sessions across all projects
189
+ fs -g
190
+
191
+ # Search only specific agent(s)
192
+ fs "bug,fix" --agents claude
193
+ fs "error" --agents codex
194
+
195
+ # Limit number of results
196
+ fs "keywords" -n 15
197
+ ```
198
+
199
+ ### Features
200
+
201
+ - **Multi-agent search**: Searches both Claude Code and Codex sessions simultaneously
202
+ - **Unified display**: Single table showing sessions from all agents with agent column
203
+ - **Smart resume**: Automatically uses correct CLI tool (`claude` or `codex`) based on selected session
204
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
205
+ - **Action menu** after session selection:
206
+ - Resume session (default)
207
+ - Show session file path
208
+ - Copy session file to file (*.jsonl) or directory
209
+ - **Project filtering**: Search current project only (default) or all projects with `-g`
210
+ - **Agent filtering**: Use `--agents claude codex` to search specific agents only
211
+ - **Configurable**: Optional config file at `~/.config/find-session/config.json` for customizing agents
212
+ - Interactive session selection with Rich table display
213
+ - Shows agent, project, git branch, date, line count, and preview
214
+ - Reverse chronological ordering (most recent first)
215
+ - Press Enter to cancel (no need for Ctrl+C)
216
+
217
+ ### Configuration (Optional)
218
+
219
+ Create `~/.config/find-session/config.json` to customize agent settings:
220
+
221
+ ```json
222
+ {
223
+ "agents": [
224
+ {
225
+ "name": "claude",
226
+ "display_name": "Claude",
227
+ "home_dir": "~/.claude",
228
+ "enabled": true
229
+ },
230
+ {
231
+ "name": "codex",
232
+ "display_name": "Codex",
233
+ "home_dir": "~/.codex",
234
+ "enabled": true
235
+ }
236
+ ]
237
+ }
238
+ ```
239
+
240
+ This allows you to:
241
+ - Enable/disable specific agents
242
+ - Override default home directories
243
+ - Customize display names
244
+ - Prepare for future agent additions
245
+
164
246
  <a id="find-claude-session"></a>
165
247
  ## 🔍 find-claude-session
166
248
 
@@ -194,13 +276,20 @@ source /path/to/claude-code-tools/scripts/fcs-function.sh
194
276
  # Search in current project
195
277
  fcs "keyword1,keyword2,keyword3"
196
278
 
197
- # Search across all Claude projects
279
+ # Show all sessions in current project (no keyword filtering)
280
+ fcs
281
+
282
+ # Search across all Claude projects
198
283
  fcs "keywords" --global
199
284
  fcs "keywords" -g
285
+
286
+ # Show all sessions across all projects
287
+ fcs -g
200
288
  ```
201
289
 
202
290
  ### Features
203
291
 
292
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
204
293
  - **Action menu** after session selection:
205
294
  - Resume session (default)
206
295
  - Show session file path
@@ -238,10 +327,16 @@ Search and resume Codex sessions by keywords. Usage is similar to `find-claude-s
238
327
  # Search in current project only (default)
239
328
  find-codex-session "keyword1,keyword2"
240
329
 
330
+ # Show all sessions in current project (no keyword filtering)
331
+ find-codex-session
332
+
241
333
  # Search across all projects
242
334
  find-codex-session "keywords" -g
243
335
  find-codex-session "keywords" --global
244
336
 
337
+ # Show all sessions across all projects
338
+ find-codex-session -g
339
+
245
340
  # Limit number of results
246
341
  find-codex-session "keywords" -n 5
247
342
 
@@ -251,6 +346,7 @@ find-codex-session "keywords" --codex-home /custom/path
251
346
 
252
347
  ### Features
253
348
 
349
+ - **Optional keyword search**: Keywords are optional—omit them to show all sessions
254
350
  - **Action menu** after session selection:
255
351
  - Resume session (default)
256
352
  - Show session file path
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.6"
@@ -124,29 +124,49 @@ def extract_project_name(original_path: str) -> str:
124
124
  def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool, int, Optional[str]]:
125
125
  """
126
126
  Check if all keywords are present in the JSONL file, count lines, and extract git branch.
127
-
127
+
128
128
  Args:
129
129
  filepath: Path to the JSONL file
130
- keywords: List of keywords to search for (case-insensitive)
131
-
130
+ keywords: List of keywords to search for (case-insensitive). Empty list matches all files.
131
+
132
132
  Returns:
133
133
  Tuple of (matches: bool, line_count: int, git_branch: Optional[str])
134
- - matches: True if ALL keywords are found in the file
134
+ - matches: True if ALL keywords are found in the file (or True if no keywords)
135
135
  - line_count: Total number of lines in the file
136
136
  - git_branch: Git branch name from the first message that has it, or None
137
137
  """
138
+ # If no keywords, match all files
139
+ if not keywords:
140
+ line_count = 0
141
+ git_branch = None
142
+ try:
143
+ with open(filepath, 'r', encoding='utf-8') as f:
144
+ for line in f:
145
+ line_count += 1
146
+ # Extract git branch from JSON if not already found
147
+ if git_branch is None:
148
+ try:
149
+ data = json.loads(line.strip())
150
+ if 'gitBranch' in data and data['gitBranch']:
151
+ git_branch = data['gitBranch']
152
+ except (json.JSONDecodeError, KeyError):
153
+ pass
154
+ except Exception:
155
+ return False, 0, None
156
+ return True, line_count, git_branch
157
+
138
158
  # Convert keywords to lowercase for case-insensitive search
139
159
  keywords_lower = [k.lower() for k in keywords]
140
160
  found_keywords = set()
141
161
  line_count = 0
142
162
  git_branch = None
143
-
163
+
144
164
  try:
145
165
  with open(filepath, 'r', encoding='utf-8') as f:
146
166
  for line in f:
147
167
  line_count += 1
148
168
  line_lower = line.lower()
149
-
169
+
150
170
  # Extract git branch from JSON if not already found
151
171
  if git_branch is None:
152
172
  try:
@@ -155,7 +175,7 @@ def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool,
155
175
  git_branch = data['gitBranch']
156
176
  except (json.JSONDecodeError, KeyError):
157
177
  pass
158
-
178
+
159
179
  # Check which keywords are in this line
160
180
  for keyword in keywords_lower:
161
181
  if keyword in line_lower:
@@ -163,7 +183,7 @@ def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool,
163
183
  except Exception:
164
184
  # Skip files that can't be read
165
185
  return False, 0, None
166
-
186
+
167
187
  matches = len(found_keywords) == len(keywords_lower)
168
188
  return matches, line_count, git_branch
169
189
 
@@ -310,22 +330,23 @@ def display_interactive_ui(sessions: List[Tuple[str, float, int, str, str, str,
310
330
  """Display interactive UI for session selection."""
311
331
  if not RICH_AVAILABLE:
312
332
  return None
313
-
333
+
314
334
  # Use stderr console if in stderr mode
315
335
  ui_console = Console(file=sys.stderr) if stderr_mode else console
316
336
  if not ui_console:
317
337
  return None
318
-
338
+
319
339
  # Limit to specified number of sessions
320
340
  display_sessions = sessions[:num_matches]
321
-
341
+
322
342
  if not display_sessions:
323
343
  ui_console.print("[red]No sessions found[/red]")
324
344
  return None
325
-
345
+
326
346
  # Create table
347
+ title = f"Sessions matching: {', '.join(keywords)}" if keywords else "All sessions"
327
348
  table = Table(
328
- title=f"Sessions matching: {', '.join(keywords)}",
349
+ title=title,
329
350
  box=box.ROUNDED,
330
351
  show_header=True,
331
352
  header_style="bold cyan"
@@ -609,7 +630,9 @@ To persist directory changes when resuming sessions:
609
630
  )
610
631
  parser.add_argument(
611
632
  "keywords",
612
- help="Comma-separated keywords to search for (case-insensitive)"
633
+ nargs='?',
634
+ default="",
635
+ help="Comma-separated keywords to search for (case-insensitive). If omitted, shows all sessions."
613
636
  )
614
637
  parser.add_argument(
615
638
  "-g", "--global",
@@ -636,11 +659,7 @@ To persist directory changes when resuming sessions:
636
659
  args = parser.parse_args()
637
660
 
638
661
  # Parse keywords
639
- keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
640
-
641
- if not keywords:
642
- print("Error: No keywords provided", file=sys.stderr)
643
- sys.exit(1)
662
+ keywords = [k.strip() for k in args.keywords.split(",") if k.strip()] if args.keywords else []
644
663
 
645
664
  # Check if searching current project only
646
665
  if not getattr(args, 'global'):
@@ -656,10 +675,11 @@ To persist directory changes when resuming sessions:
656
675
 
657
676
  if not matching_sessions:
658
677
  scope = "all projects" if getattr(args, 'global') else "current project"
678
+ keyword_msg = f" containing all keywords: {', '.join(keywords)}" if keywords else ""
659
679
  if RICH_AVAILABLE and console and not args.shell:
660
- console.print(f"[yellow]No sessions found containing all keywords in {scope}:[/yellow] {', '.join(keywords)}")
680
+ console.print(f"[yellow]No sessions found{keyword_msg} in {scope}[/yellow]")
661
681
  else:
662
- print(f"No sessions found containing all keywords in {scope}: {', '.join(keywords)}", file=sys.stderr)
682
+ print(f"No sessions found{keyword_msg} in {scope}", file=sys.stderr)
663
683
  sys.exit(0)
664
684
 
665
685
  # If we have rich and there are results, show interactive UI
@@ -112,10 +112,44 @@ def search_keywords_in_file(
112
112
  Search for keywords in a Codex session file.
113
113
 
114
114
  Returns: (found, line_count, preview)
115
- - found: True if all keywords found (case-insensitive AND logic)
115
+ - found: True if all keywords found (case-insensitive AND logic), or True if no keywords
116
116
  - line_count: total lines in file
117
117
  - preview: best user message content (skips system messages)
118
118
  """
119
+ # If no keywords, match all files
120
+ if not keywords:
121
+ line_count = 0
122
+ last_user_message = None
123
+ try:
124
+ with open(session_file, "r", encoding="utf-8") as f:
125
+ for line in f:
126
+ line_count += 1
127
+ if not line.strip():
128
+ continue
129
+ try:
130
+ entry = json.loads(line)
131
+ # Extract user messages (skip system messages)
132
+ if (
133
+ entry.get("type") == "response_item"
134
+ and entry.get("payload", {}).get("role") == "user"
135
+ ):
136
+ content = entry.get("payload", {}).get("content", [])
137
+ if isinstance(content, list) and len(content) > 0:
138
+ first_item = content[0]
139
+ if isinstance(first_item, dict):
140
+ text = first_item.get("text", "")
141
+ if text and not is_system_message(text):
142
+ cleaned = text[:400].replace("\n", " ").strip()
143
+ if len(cleaned) > 20:
144
+ last_user_message = cleaned
145
+ elif last_user_message is None:
146
+ last_user_message = cleaned
147
+ except json.JSONDecodeError:
148
+ continue
149
+ return True, line_count, last_user_message
150
+ except (OSError, IOError):
151
+ return False, 0, None
152
+
119
153
  keywords_lower = [k.lower() for k in keywords]
120
154
  found_keywords = set()
121
155
  line_count = 0
@@ -277,6 +311,7 @@ def find_sessions(
277
311
 
278
312
  def display_interactive_ui(
279
313
  matches: list[dict],
314
+ keywords: list[str] = None,
280
315
  ) -> Optional[dict]:
281
316
  """
282
317
  Display matches in interactive UI and get user selection.
@@ -289,7 +324,8 @@ def display_interactive_ui(
289
324
 
290
325
  if RICH_AVAILABLE:
291
326
  console = Console()
292
- table = Table(title="Codex Sessions", show_header=True)
327
+ title = f"Codex Sessions matching: {', '.join(keywords)}" if keywords else "All Codex Sessions"
328
+ table = Table(title=title, show_header=True)
293
329
  table.add_column("#", style="cyan", justify="right")
294
330
  table.add_column("Session ID", style="yellow", no_wrap=True)
295
331
  table.add_column("Project", style="green")
@@ -486,7 +522,9 @@ Examples:
486
522
 
487
523
  parser.add_argument(
488
524
  "keywords",
489
- help="Comma-separated keywords to search (AND logic)",
525
+ nargs='?',
526
+ default="",
527
+ help="Comma-separated keywords to search (AND logic). If omitted, shows all sessions.",
490
528
  )
491
529
  parser.add_argument(
492
530
  "-g",
@@ -515,10 +553,7 @@ Examples:
515
553
  args = parser.parse_args()
516
554
 
517
555
  # Parse keywords
518
- keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
519
- if not keywords:
520
- print("Error: No keywords provided", file=sys.stderr)
521
- sys.exit(1)
556
+ keywords = [k.strip() for k in args.keywords.split(",") if k.strip()] if args.keywords else []
522
557
 
523
558
  # Get Codex home
524
559
  codex_home = get_codex_home(args.codex_home)
@@ -532,7 +567,7 @@ Examples:
532
567
  )
533
568
 
534
569
  # Display and get selection
535
- selected_match = display_interactive_ui(matches)
570
+ selected_match = display_interactive_ui(matches, keywords)
536
571
  if not selected_match:
537
572
  return
538
573
 
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unified session finder - search across multiple coding agents (Claude Code, Codex, etc.)
4
+
5
+ Usage:
6
+ find-session [keywords] [OPTIONS]
7
+ fs [keywords] [OPTIONS] # via shell wrapper
8
+
9
+ Examples:
10
+ find-session "langroid,MCP" # Search all agents in current project
11
+ find-session -g # Show all sessions across all projects
12
+ find-session "bug" --agents claude # Search only Claude sessions
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ # Import search functions from existing tools
24
+ from claude_code_tools.find_claude_session import (
25
+ find_sessions as find_claude_sessions,
26
+ resume_session as resume_claude_session,
27
+ get_session_file_path as get_claude_session_file_path,
28
+ copy_session_file as copy_claude_session_file,
29
+ )
30
+ from claude_code_tools.find_codex_session import (
31
+ find_sessions as find_codex_sessions,
32
+ resume_session as resume_codex_session,
33
+ get_codex_home,
34
+ copy_session_file as copy_codex_session_file,
35
+ )
36
+
37
+ try:
38
+ from rich.console import Console
39
+ from rich.table import Table
40
+ from rich import box
41
+
42
+ RICH_AVAILABLE = True
43
+ except ImportError:
44
+ RICH_AVAILABLE = False
45
+
46
+
47
+ @dataclass
48
+ class AgentConfig:
49
+ """Configuration for a coding agent."""
50
+
51
+ name: str # Internal name (e.g., "claude", "codex")
52
+ display_name: str # Display name (e.g., "Claude", "Codex")
53
+ home_dir: Optional[str] # Custom home directory (None = default)
54
+ enabled: bool = True
55
+
56
+
57
+ def get_default_agents() -> List[AgentConfig]:
58
+ """Get default agent configurations."""
59
+ return [
60
+ AgentConfig(name="claude", display_name="Claude", home_dir=None),
61
+ AgentConfig(name="codex", display_name="Codex", home_dir=None),
62
+ ]
63
+
64
+
65
+ def load_config() -> List[AgentConfig]:
66
+ """Load agent configuration from config file or use defaults."""
67
+ config_path = Path.home() / ".config" / "find-session" / "config.json"
68
+
69
+ if config_path.exists():
70
+ try:
71
+ with open(config_path, "r") as f:
72
+ data = json.load(f)
73
+ agents = []
74
+ for agent_data in data.get("agents", []):
75
+ agents.append(
76
+ AgentConfig(
77
+ name=agent_data["name"],
78
+ display_name=agent_data.get(
79
+ "display_name", agent_data["name"].title()
80
+ ),
81
+ home_dir=agent_data.get("home_dir"),
82
+ enabled=agent_data.get("enabled", True),
83
+ )
84
+ )
85
+ return agents
86
+ except (json.JSONDecodeError, KeyError, IOError):
87
+ pass
88
+
89
+ # Return defaults if config doesn't exist or is invalid
90
+ return get_default_agents()
91
+
92
+
93
+ def search_all_agents(
94
+ keywords: List[str],
95
+ global_search: bool = False,
96
+ num_matches: int = 10,
97
+ agents: Optional[List[str]] = None,
98
+ claude_home: Optional[str] = None,
99
+ codex_home: Optional[str] = None,
100
+ ) -> List[dict]:
101
+ """
102
+ Search sessions across all enabled agents.
103
+
104
+ Returns list of dicts with agent metadata added.
105
+ """
106
+ agent_configs = load_config()
107
+
108
+ # Filter by requested agents if specified
109
+ if agents:
110
+ agent_configs = [a for a in agent_configs if a.name in agents]
111
+
112
+ # Filter by enabled agents
113
+ agent_configs = [a for a in agent_configs if a.enabled]
114
+
115
+ all_sessions = []
116
+
117
+ for agent_config in agent_configs:
118
+ if agent_config.name == "claude":
119
+ # Search Claude sessions
120
+ home = claude_home or agent_config.home_dir
121
+ sessions = find_claude_sessions(
122
+ keywords, global_search=global_search, claude_home=home
123
+ )
124
+
125
+ # Add agent metadata to each session
126
+ for session in sessions:
127
+ session_dict = {
128
+ "agent": "claude",
129
+ "agent_display": agent_config.display_name,
130
+ "session_id": session[0],
131
+ "mod_time": session[1],
132
+ "create_time": session[2],
133
+ "lines": session[3],
134
+ "project": session[4],
135
+ "preview": session[5],
136
+ "cwd": session[6],
137
+ "branch": session[7] if len(session) > 7 else "",
138
+ "claude_home": home,
139
+ }
140
+ all_sessions.append(session_dict)
141
+
142
+ elif agent_config.name == "codex":
143
+ # Search Codex sessions
144
+ home = codex_home or agent_config.home_dir
145
+ codex_home_path = get_codex_home(home)
146
+
147
+ if codex_home_path.exists():
148
+ sessions = find_codex_sessions(
149
+ codex_home_path,
150
+ keywords,
151
+ num_matches=num_matches * 2, # Get more for merging
152
+ global_search=global_search,
153
+ )
154
+
155
+ # Add agent metadata to each session
156
+ for session in sessions:
157
+ session_dict = {
158
+ "agent": "codex",
159
+ "agent_display": agent_config.display_name,
160
+ "session_id": session["session_id"],
161
+ "mod_time": session["mod_time"],
162
+ "create_time": session.get("mod_time"), # Codex doesn't separate these
163
+ "lines": session["lines"],
164
+ "project": session["project"],
165
+ "preview": session["preview"],
166
+ "cwd": session["cwd"],
167
+ "branch": session.get("branch", ""),
168
+ "file_path": session.get("file_path", ""),
169
+ }
170
+ all_sessions.append(session_dict)
171
+
172
+ # Sort by modification time (newest first) and limit
173
+ all_sessions.sort(key=lambda x: x["mod_time"], reverse=True)
174
+ return all_sessions[:num_matches]
175
+
176
+
177
+ def display_interactive_ui(
178
+ sessions: List[dict], keywords: List[str], stderr_mode: bool = False, num_matches: int = 10
179
+ ) -> Optional[dict]:
180
+ """Display unified session selection UI."""
181
+ if not RICH_AVAILABLE:
182
+ return None
183
+
184
+ # Use stderr console if in stderr mode
185
+ ui_console = Console(file=sys.stderr) if stderr_mode else Console()
186
+
187
+ # Limit to specified number of sessions
188
+ display_sessions = sessions[:num_matches]
189
+
190
+ if not display_sessions:
191
+ ui_console.print("[red]No sessions found[/red]")
192
+ return None
193
+
194
+ # Create table
195
+ title = (
196
+ f"Sessions matching: {', '.join(keywords)}" if keywords else "All sessions"
197
+ )
198
+ table = Table(
199
+ title=title, box=box.ROUNDED, show_header=True, header_style="bold cyan"
200
+ )
201
+
202
+ table.add_column("#", style="bold yellow", width=3)
203
+ table.add_column("Agent", style="magenta", width=6)
204
+ table.add_column("Session ID", style="dim", width=10)
205
+ table.add_column("Project", style="green")
206
+ table.add_column("Branch", style="cyan")
207
+ table.add_column("Date", style="blue")
208
+ table.add_column("Lines", style="cyan", justify="right", width=6)
209
+ table.add_column("Last User Message", style="white", max_width=50, overflow="fold")
210
+
211
+ for idx, session in enumerate(display_sessions, 1):
212
+ # Format date from mod_time
213
+ from datetime import datetime
214
+
215
+ mod_time = session["mod_time"]
216
+ date_str = datetime.fromtimestamp(mod_time).strftime("%m/%d %H:%M")
217
+
218
+ branch_display = session.get("branch", "") or "N/A"
219
+
220
+ table.add_row(
221
+ str(idx),
222
+ session["agent_display"],
223
+ session["session_id"][:8] + "...",
224
+ session["project"],
225
+ branch_display,
226
+ date_str,
227
+ str(session["lines"]),
228
+ session["preview"],
229
+ )
230
+
231
+ ui_console.print(table)
232
+ ui_console.print("\n[bold]Select a session:[/bold]")
233
+ ui_console.print(f" • Enter number (1-{len(display_sessions)}) to select")
234
+ ui_console.print(" • Press Enter to cancel\n")
235
+
236
+ while True:
237
+ try:
238
+ from rich.prompt import Prompt
239
+
240
+ choice = Prompt.ask(
241
+ "Your choice", default="", show_default=False, console=ui_console
242
+ )
243
+
244
+ # Handle empty input - cancel
245
+ if not choice or not choice.strip():
246
+ ui_console.print("[yellow]Cancelled[/yellow]")
247
+ return None
248
+
249
+ idx = int(choice) - 1
250
+ if 0 <= idx < len(display_sessions):
251
+ return display_sessions[idx]
252
+ else:
253
+ ui_console.print("[red]Invalid choice. Please try again.[/red]")
254
+
255
+ except KeyboardInterrupt:
256
+ ui_console.print("\n[yellow]Cancelled[/yellow]")
257
+ return None
258
+ except EOFError:
259
+ ui_console.print("\n[yellow]Cancelled (EOF)[/yellow]")
260
+ return None
261
+ except ValueError:
262
+ ui_console.print("[red]Invalid choice. Please try again.[/red]")
263
+
264
+
265
+ def show_action_menu(session: dict) -> Optional[str]:
266
+ """Show action menu for selected session."""
267
+ print(f"\n=== Session: {session['session_id'][:16]}... ===")
268
+ print(f"Agent: {session['agent_display']}")
269
+ print(f"Project: {session['project']}")
270
+ if session.get("branch"):
271
+ print(f"Branch: {session['branch']}")
272
+ print(f"\nWhat would you like to do?")
273
+ print("1. Resume session (default)")
274
+ print("2. Show session file path")
275
+ print("3. Copy session file to file (*.jsonl) or directory")
276
+ print()
277
+
278
+ try:
279
+ choice = input("Enter choice [1-3] (or Enter for 1): ").strip()
280
+ if not choice or choice == "1":
281
+ return "resume"
282
+ elif choice == "2":
283
+ return "path"
284
+ elif choice == "3":
285
+ return "copy"
286
+ else:
287
+ print("Invalid choice.")
288
+ return None
289
+ except KeyboardInterrupt:
290
+ print("\nCancelled.")
291
+ return None
292
+
293
+
294
+ def handle_action(session: dict, action: str, shell_mode: bool = False) -> None:
295
+ """Handle the selected action based on agent type."""
296
+ agent = session["agent"]
297
+
298
+ if action == "resume":
299
+ if agent == "claude":
300
+ resume_claude_session(
301
+ session["session_id"],
302
+ session["cwd"],
303
+ shell_mode=shell_mode,
304
+ claude_home=session.get("claude_home"),
305
+ )
306
+ elif agent == "codex":
307
+ resume_codex_session(
308
+ session["session_id"], session["cwd"], shell_mode=shell_mode
309
+ )
310
+
311
+ elif action == "path":
312
+ if agent == "claude":
313
+ file_path = get_claude_session_file_path(
314
+ session["session_id"],
315
+ session["cwd"],
316
+ claude_home=session.get("claude_home"),
317
+ )
318
+ print(f"\nSession file path:")
319
+ print(file_path)
320
+ elif agent == "codex":
321
+ print(f"\nSession file path:")
322
+ print(session.get("file_path", "Unknown"))
323
+
324
+ elif action == "copy":
325
+ if agent == "claude":
326
+ file_path = get_claude_session_file_path(
327
+ session["session_id"],
328
+ session["cwd"],
329
+ claude_home=session.get("claude_home"),
330
+ )
331
+ copy_claude_session_file(file_path)
332
+ elif agent == "codex":
333
+ copy_codex_session_file(session.get("file_path", ""))
334
+
335
+
336
+ def main():
337
+ parser = argparse.ArgumentParser(
338
+ description="Unified session finder - search across multiple coding agents",
339
+ formatter_class=argparse.RawDescriptionHelpFormatter,
340
+ epilog="""
341
+ Examples:
342
+ find-session "langroid,MCP" # Search all agents in current project
343
+ find-session -g # Show all sessions across all projects
344
+ find-session "bug" --agents claude # Search only Claude sessions
345
+ find-session "error" --agents codex # Search only Codex sessions
346
+ """,
347
+ )
348
+ parser.add_argument(
349
+ "keywords",
350
+ nargs="?",
351
+ default="",
352
+ help="Comma-separated keywords to search (AND logic). If omitted, shows all sessions.",
353
+ )
354
+ parser.add_argument(
355
+ "-g",
356
+ "--global",
357
+ dest="global_search",
358
+ action="store_true",
359
+ help="Search across all projects, not just the current one",
360
+ )
361
+ parser.add_argument(
362
+ "-n",
363
+ "--num-matches",
364
+ type=int,
365
+ default=10,
366
+ help="Number of matching sessions to display (default: 10)",
367
+ )
368
+ parser.add_argument(
369
+ "--agents",
370
+ nargs="+",
371
+ choices=["claude", "codex"],
372
+ help="Limit search to specific agents (default: all)",
373
+ )
374
+ parser.add_argument(
375
+ "--shell",
376
+ action="store_true",
377
+ help="Output shell commands for evaluation (for use with shell function)",
378
+ )
379
+ parser.add_argument(
380
+ "--claude-home", type=str, help="Path to Claude home directory (default: ~/.claude)"
381
+ )
382
+ parser.add_argument(
383
+ "--codex-home", type=str, help="Path to Codex home directory (default: ~/.codex)"
384
+ )
385
+
386
+ args = parser.parse_args()
387
+
388
+ # Parse keywords
389
+ keywords = (
390
+ [k.strip() for k in args.keywords.split(",") if k.strip()]
391
+ if args.keywords
392
+ else []
393
+ )
394
+
395
+ # Search all agents
396
+ matching_sessions = search_all_agents(
397
+ keywords,
398
+ global_search=args.global_search,
399
+ num_matches=args.num_matches,
400
+ agents=args.agents,
401
+ claude_home=args.claude_home,
402
+ codex_home=args.codex_home,
403
+ )
404
+
405
+ if not matching_sessions:
406
+ scope = "all projects" if args.global_search else "current project"
407
+ keyword_msg = (
408
+ f" containing all keywords: {', '.join(keywords)}" if keywords else ""
409
+ )
410
+ if RICH_AVAILABLE:
411
+ console = Console()
412
+ console.print(f"[yellow]No sessions found{keyword_msg} in {scope}[/yellow]")
413
+ else:
414
+ print(f"No sessions found{keyword_msg} in {scope}", file=sys.stderr)
415
+ sys.exit(0)
416
+
417
+ # Display interactive UI
418
+ if RICH_AVAILABLE:
419
+ selected_session = display_interactive_ui(
420
+ matching_sessions, keywords, stderr_mode=args.shell, num_matches=args.num_matches
421
+ )
422
+ if selected_session:
423
+ # Show action menu
424
+ action = show_action_menu(selected_session)
425
+ if action:
426
+ handle_action(selected_session, action, shell_mode=args.shell)
427
+ else:
428
+ # Fallback without rich
429
+ print("\nMatching sessions:")
430
+ for idx, session in enumerate(matching_sessions[: args.num_matches], 1):
431
+ print(
432
+ f"{idx}. [{session['agent_display']}] {session['session_id'][:16]}... | "
433
+ f"{session['project']} | {session.get('branch', 'N/A')}"
434
+ )
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Collection of tools for working with Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -17,6 +17,8 @@ dev = ["commitizen>=3.0.0"]
17
17
  [project.scripts]
18
18
  find-claude-session = "claude_code_tools.find_claude_session:main"
19
19
  find-codex-session = "claude_code_tools.find_codex_session:main"
20
+ find-session = "claude_code_tools.find_session:main"
21
+ fs = "claude_code_tools.find_session:main"
20
22
  vault = "claude_code_tools.dotenv_vault:main"
21
23
  tmux-cli = "claude_code_tools.tmux_cli_controller:main"
22
24
  env-safe = "claude_code_tools.env_safe:main"
@@ -43,7 +45,7 @@ exclude = [
43
45
 
44
46
  [tool.commitizen]
45
47
  name = "cz_conventional_commits"
46
- version = "0.2.4"
48
+ version = "0.2.6"
47
49
  tag_format = "v$version"
48
50
  version_files = [
49
51
  "pyproject.toml:version",