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.
- ccbashhistory/__init__.py +3 -0
- ccbashhistory/cli.py +295 -0
- ccbashhistory/extractor.py +52 -0
- ccbashhistory-1.0.0.dist-info/METADATA +47 -0
- ccbashhistory-1.0.0.dist-info/RECORD +8 -0
- ccbashhistory-1.0.0.dist-info/WHEEL +5 -0
- ccbashhistory-1.0.0.dist-info/entry_points.txt +2 -0
- ccbashhistory-1.0.0.dist-info/top_level.txt +1 -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 @@
|
|
|
1
|
+
ccbashhistory
|