ccbashhistory 1.0.0__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.
@@ -0,0 +1,3 @@
1
+ """ccbashhistory - Extract and analyze bash commands from Claude Code session history."""
2
+
3
+ __version__ = "1.0.0"
ccbashhistory/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import sys
5
+ import tty
6
+ import termios
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+
10
+
11
+ # ANSI color codes
12
+ class Colors:
13
+ RESET = '\033[0m'
14
+ BOLD = '\033[1m'
15
+ DIM = '\033[2m'
16
+ GREEN = '\033[32m'
17
+ YELLOW = '\033[33m'
18
+ BLUE = '\033[34m'
19
+ MAGENTA = '\033[35m'
20
+ CYAN = '\033[36m'
21
+ BRIGHT_BLACK = '\033[90m'
22
+ BRIGHT_CYAN = '\033[96m'
23
+
24
+
25
+ def find_claude_sessions(hours=24):
26
+ """Find all Claude Code session files modified in the last N hours."""
27
+ home = Path.home()
28
+ claude_dir = home / ".claude" / "projects"
29
+
30
+ if not claude_dir.exists():
31
+ print(f"Error: Claude projects directory not found at {claude_dir}")
32
+ sys.exit(1)
33
+
34
+ cutoff_time = datetime.now() - timedelta(hours=hours)
35
+ sessions = []
36
+
37
+ # Iterate through all project directories
38
+ for project_dir in claude_dir.iterdir():
39
+ if not project_dir.is_dir():
40
+ continue
41
+
42
+ project_name = project_dir.name
43
+
44
+ # Find all .jsonl files in this project
45
+ for jsonl_file in project_dir.glob("*.jsonl"):
46
+ mtime = datetime.fromtimestamp(jsonl_file.stat().st_mtime)
47
+
48
+ if mtime >= cutoff_time:
49
+ # Try to get session info from the first line
50
+ session_info = get_session_info(jsonl_file)
51
+
52
+ sessions.append({
53
+ 'path': jsonl_file,
54
+ 'project': project_name,
55
+ 'modified': mtime,
56
+ 'size': jsonl_file.stat().st_size,
57
+ 'session_id': session_info.get('session_id'),
58
+ 'branch': session_info.get('branch'),
59
+ 'cwd': session_info.get('cwd'),
60
+ 'title': session_info.get('title'),
61
+ 'message_count': session_info.get('message_count', 0)
62
+ })
63
+
64
+ # Sort by modification time (newest first)
65
+ sessions.sort(key=lambda x: x['modified'], reverse=True)
66
+ return sessions
67
+
68
+
69
+ def get_session_info(jsonl_file):
70
+ """Extract session info and count messages from a JSONL file."""
71
+ info = {'message_count': 0}
72
+
73
+ try:
74
+ with open(jsonl_file, 'r') as f:
75
+ # Read all lines to count messages and get metadata
76
+ for i, line in enumerate(f):
77
+ try:
78
+ data = json.loads(line)
79
+
80
+ # Count user and assistant messages
81
+ if data.get('type') in ('user', 'assistant'):
82
+ info['message_count'] += 1
83
+
84
+ # Look for session metadata (only in first 50 lines)
85
+ if i < 50:
86
+ if not info.get('session_id') and 'sessionId' in data:
87
+ info['session_id'] = data['sessionId']
88
+
89
+ if not info.get('branch') and 'gitBranch' in data:
90
+ info['branch'] = data['gitBranch']
91
+
92
+ if not info.get('cwd') and 'cwd' in data:
93
+ info['cwd'] = data['cwd']
94
+
95
+ # Look for session title in user messages
96
+ if not info.get('title') and data.get('type') == 'user':
97
+ message = data.get('message', {})
98
+ if isinstance(message, dict):
99
+ content = message.get('content')
100
+ if isinstance(content, str) and content.strip():
101
+ # Use first user message as title (truncate if too long)
102
+ title = content.strip().split('\n')[0][:80]
103
+ info['title'] = title
104
+
105
+ except json.JSONDecodeError:
106
+ continue
107
+
108
+ except Exception as e:
109
+ pass
110
+
111
+ return info
112
+
113
+
114
+ def format_size(size_bytes):
115
+ """Format bytes to human-readable size."""
116
+ for unit in ['B', 'KB', 'MB', 'GB']:
117
+ if size_bytes < 1024.0:
118
+ return f"{size_bytes:.1f}{unit}"
119
+ size_bytes /= 1024.0
120
+ return f"{size_bytes:.1f}TB"
121
+
122
+
123
+ def format_time_ago(dt):
124
+ """Format datetime as time ago."""
125
+ now = datetime.now()
126
+ diff = now - dt
127
+
128
+ if diff.seconds < 60:
129
+ return "just now"
130
+ elif diff.seconds < 3600:
131
+ mins = diff.seconds // 60
132
+ return f"{mins}m ago"
133
+ elif diff.seconds < 86400:
134
+ hours = diff.seconds // 3600
135
+ return f"{hours}h ago"
136
+ else:
137
+ return dt.strftime("%Y-%m-%d %H:%M")
138
+
139
+
140
+ def display_sessions(sessions, selected_idx=None, scroll_offset=0, visible_count=10):
141
+ """Display sessions in a scrollable window with optional highlighting."""
142
+ print(f"\n{Colors.BOLD}{Colors.CYAN}╔{'═' * 78}╗{Colors.RESET}")
143
+ print(f"{Colors.BOLD}{Colors.CYAN}║{Colors.RESET} {Colors.BOLD}Claude Code Sessions (Last 24 Hours){Colors.RESET}{' ' * 39}{Colors.BOLD}{Colors.CYAN}║{Colors.RESET}")
144
+ print(f"{Colors.BOLD}{Colors.CYAN}╚{'═' * 78}╝{Colors.RESET}\n")
145
+
146
+ total_sessions = len(sessions)
147
+ end_offset = min(scroll_offset + visible_count, total_sessions)
148
+
149
+ # Show scroll indicator at top if not at the beginning
150
+ if scroll_offset > 0:
151
+ print(f"{Colors.BRIGHT_BLACK} ↑ {scroll_offset} more above ↑{Colors.RESET}")
152
+ print()
153
+
154
+ for i in range(scroll_offset, end_offset):
155
+ session = sessions[i]
156
+
157
+ # Extract project name from path (make it more readable)
158
+ project = session['project']
159
+
160
+ # Get working directory basename if available
161
+ cwd_name = ""
162
+ if session['cwd']:
163
+ cwd_name = os.path.basename(session['cwd'])
164
+
165
+ # Build the title/info line
166
+ title = session.get('title', 'Untitled Session')
167
+
168
+ # Build metadata parts
169
+ metadata_parts = []
170
+ if cwd_name:
171
+ metadata_parts.append(f"{Colors.BLUE}{cwd_name}{Colors.RESET}")
172
+ if session['branch']:
173
+ metadata_parts.append(f"{Colors.MAGENTA}{session['branch']}{Colors.RESET}")
174
+
175
+ metadata_parts.append(f"{Colors.BRIGHT_BLACK}{format_time_ago(session['modified'])}{Colors.RESET}")
176
+
177
+ # Add message count and size together
178
+ msg_count = session.get('message_count', 0)
179
+ size_str = format_size(session['size'])
180
+ metadata_parts.append(f"{Colors.BRIGHT_BLACK}{msg_count} msgs · {size_str}{Colors.RESET}")
181
+
182
+ metadata = f"{Colors.BRIGHT_BLACK}│{Colors.RESET} ".join(metadata_parts)
183
+
184
+ # Highlight selected item
185
+ is_selected = selected_idx is not None and i == selected_idx
186
+ prefix = "► " if is_selected else " "
187
+ num_color = Colors.BOLD + Colors.CYAN if is_selected else Colors.BOLD + Colors.YELLOW
188
+
189
+ # Format the display - compact single line per session
190
+ print(f"{prefix}{num_color}[{i+1:2d}]{Colors.RESET} {Colors.GREEN}{title}{Colors.RESET}")
191
+ print(f" {metadata}")
192
+ print()
193
+
194
+ # Show scroll indicator at bottom if more items below
195
+ if end_offset < total_sessions:
196
+ print(f"{Colors.BRIGHT_BLACK} ↓ {total_sessions - end_offset} more below ↓{Colors.RESET}")
197
+ print()
198
+
199
+ print(f"{Colors.BRIGHT_BLACK}{'─' * 80}{Colors.RESET}")
200
+
201
+ # Show position indicator
202
+ if total_sessions > visible_count:
203
+ print(f"{Colors.BRIGHT_BLACK}Showing {scroll_offset + 1}-{end_offset} of {total_sessions}{Colors.RESET}")
204
+
205
+
206
+ def get_arrow_key():
207
+ """Read a single key press, including arrow keys."""
208
+ fd = sys.stdin.fileno()
209
+ old_settings = termios.tcgetattr(fd)
210
+ try:
211
+ tty.setraw(fd)
212
+ ch = sys.stdin.read(1)
213
+
214
+ # Check for escape sequences (arrow keys)
215
+ if ch == '\x1b':
216
+ ch2 = sys.stdin.read(1)
217
+ if ch2 == '[':
218
+ ch3 = sys.stdin.read(1)
219
+ if ch3 == 'A':
220
+ return 'UP'
221
+ elif ch3 == 'B':
222
+ return 'DOWN'
223
+ elif ch == '\r' or ch == '\n':
224
+ return 'ENTER'
225
+ elif ch == 'q' or ch == 'Q':
226
+ return 'QUIT'
227
+ elif ch == '\x03': # Ctrl+C
228
+ return 'QUIT'
229
+
230
+ return ch
231
+ finally:
232
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
233
+
234
+
235
+ def main():
236
+ # Find all recent sessions
237
+ sessions = find_claude_sessions(hours=24)
238
+
239
+ if not sessions:
240
+ print("No Claude Code sessions found in the last 24 hours.")
241
+ sys.exit(0)
242
+
243
+ # Interactive selection with arrow keys
244
+ selected_idx = 0
245
+ visible_count = 10 # Number of sessions visible at once
246
+ scroll_offset = 0
247
+
248
+ while True:
249
+ # Calculate scroll offset to keep selected item visible
250
+ if selected_idx < scroll_offset:
251
+ scroll_offset = selected_idx
252
+ elif selected_idx >= scroll_offset + visible_count:
253
+ scroll_offset = selected_idx - visible_count + 1
254
+
255
+ # Clear screen and display sessions
256
+ os.system('clear' if os.name != 'nt' else 'cls')
257
+ display_sessions(sessions, selected_idx, scroll_offset, visible_count)
258
+
259
+ print(f"\n{Colors.BRIGHT_BLACK}Use ↑/↓ arrows to navigate, Enter to select, q to quit{Colors.RESET}")
260
+
261
+ # Get key press
262
+ key = get_arrow_key()
263
+
264
+ if key == 'UP':
265
+ selected_idx = (selected_idx - 1) % len(sessions)
266
+ elif key == 'DOWN':
267
+ selected_idx = (selected_idx + 1) % len(sessions)
268
+ elif key == 'ENTER':
269
+ selected = sessions[selected_idx]
270
+ break
271
+ elif key == 'QUIT':
272
+ print(f"\n{Colors.BRIGHT_BLACK}Goodbye!{Colors.RESET}")
273
+ sys.exit(0)
274
+ elif key.isdigit():
275
+ # Allow direct number entry
276
+ num = int(key)
277
+ if 1 <= num <= len(sessions):
278
+ selected = sessions[num - 1]
279
+ break
280
+
281
+ # Clear screen one more time
282
+ os.system('clear' if os.name != 'nt' else 'cls')
283
+
284
+ # Run the extraction script
285
+ print(f"\n{Colors.BOLD}{Colors.CYAN}Analyzing session:{Colors.RESET} {Colors.GREEN}{selected.get('title', 'Untitled Session')}{Colors.RESET}")
286
+ print(f"{Colors.BRIGHT_BLACK}{'─' * 80}{Colors.RESET}\n")
287
+
288
+ # Import and run the extractor directly
289
+ from ccbashhistory.extractor import extract_and_display_bash_commands
290
+
291
+ extract_and_display_bash_commands(str(selected['path']))
292
+
293
+
294
+ if __name__ == '__main__':
295
+ main()
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import sys
4
+
5
+ def extract_bash_commands(jsonl_file):
6
+ commands = []
7
+
8
+ with open(jsonl_file, 'r') as f:
9
+ for line_num, line in enumerate(f, 1):
10
+ try:
11
+ data = json.loads(line)
12
+
13
+ # Check if this is an assistant message with tool calls
14
+ if data.get('type') == 'assistant' and 'message' in data:
15
+ message = data['message']
16
+ if 'content' in message and isinstance(message['content'], list):
17
+ for block in message['content']:
18
+ if isinstance(block, dict) and block.get('type') == 'tool_use':
19
+ if block.get('name') == 'Bash':
20
+ params = block.get('input', {})
21
+ if 'command' in params:
22
+ commands.append({
23
+ 'line': line_num,
24
+ 'command': params['command'],
25
+ 'description': params.get('description', 'N/A'),
26
+ 'timestamp': data.get('timestamp', 'N/A')
27
+ })
28
+ except json.JSONDecodeError:
29
+ print(f"Warning: Could not parse line {line_num}", file=sys.stderr)
30
+ continue
31
+
32
+ return commands
33
+
34
+ def extract_and_display_bash_commands(jsonl_file):
35
+ """Extract and display bash commands from a JSONL file."""
36
+ commands = extract_bash_commands(jsonl_file)
37
+
38
+ print(f"Found {len(commands)} bash commands:\n")
39
+ print("=" * 80)
40
+
41
+ for i, cmd_info in enumerate(commands, 1):
42
+ print(f"\n[Command {i}] (Line {cmd_info['line']}) - {cmd_info['timestamp']}")
43
+ print(f"Description: {cmd_info['description']}")
44
+ print(f"Command:\n{cmd_info['command']}")
45
+ print("-" * 80)
46
+
47
+ if __name__ == '__main__':
48
+ if len(sys.argv) != 2:
49
+ print("Usage: python extract_bash_commands.py <jsonl_file>")
50
+ sys.exit(1)
51
+
52
+ extract_and_display_bash_commands(sys.argv[1])
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccbashhistory
3
+ Version: 1.0.0
4
+ Summary: Extract and analyze bash commands from Claude Code session history
5
+ Author: pdenya
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pdenya/ccbashhistory
8
+ Project-URL: Repository, https://github.com/pdenya/ccbashhistory
9
+ Project-URL: Issues, https://github.com/pdenya/ccbashhistory/issues
10
+ Keywords: claude,bash,history,command-line
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # ccbashhistory
25
+
26
+ Extract and analyze bash commands from Claude Code session history.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pipx run ccbashhistory
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Shows an interactive list of your recent Claude Code sessions. Use arrow keys to navigate and press Enter to select a session to analyze.
37
+ Prints all the bash commands run by claude code during this session
38
+
39
+ ## Development
40
+
41
+ ```bash
42
+ pipx run --no-cache --spec . ccbashhistory
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,8 @@
1
+ ccbashhistory/__init__.py,sha256=Xs4KvPP8DLo-n9M3U9SzhvAlOr2OYDvcoyFjyhD5xhU,113
2
+ ccbashhistory/cli.py,sha256=2hIKKeLRk1MrbLofWgrZzL8EHYXyfq617GG17lYRPtw,10300
3
+ ccbashhistory/extractor.py,sha256=L-d46r74Slg3IKasr4pwRfwBAAUrO1dZXAWvHfJO8CU,2152
4
+ ccbashhistory-1.0.0.dist-info/METADATA,sha256=FF6iVYlC-uMFg72B8_Y6iiao9SC87wdr-rVeqnNOouw,1420
5
+ ccbashhistory-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ ccbashhistory-1.0.0.dist-info/entry_points.txt,sha256=xD7pL_vccitE6txhE0gmGRdzvsfyY54O9s5ScGRrLRQ,57
7
+ ccbashhistory-1.0.0.dist-info/top_level.txt,sha256=KbAP1f6JvXwWAh6-MmkZPKzZf2sV3E60aUt9_2tQLog,14
8
+ ccbashhistory-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccbashhistory = ccbashhistory.cli:main
@@ -0,0 +1 @@
1
+ ccbashhistory