learn_bash_from_session_data 1.0.0 → 1.0.2
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.
- package/bin/learn-bash.js +52 -2
- package/package.json +1 -1
- package/scripts/analyzer.py +64 -0
- package/scripts/extractor.py +37 -0
- package/scripts/html_generator.py +123 -2
- package/scripts/main.py +84 -15
- package/scripts/parser.py +42 -5
- package/scripts/quiz_generator.py +31 -0
package/bin/learn-bash.js
CHANGED
|
@@ -107,12 +107,56 @@ ${colors.bright}SESSION LOCATION:${colors.reset}
|
|
|
107
107
|
`);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Check if running in WSL
|
|
112
|
+
*/
|
|
113
|
+
function isWSL() {
|
|
114
|
+
try {
|
|
115
|
+
const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
|
|
116
|
+
return version.includes('microsoft') || version.includes('wsl');
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
/**
|
|
111
123
|
* Get the Claude projects directory path
|
|
124
|
+
* Handles WSL by checking both Linux and Windows paths
|
|
112
125
|
*/
|
|
113
126
|
function getClaudeProjectsDir() {
|
|
114
127
|
const homeDir = os.homedir();
|
|
115
|
-
|
|
128
|
+
const linuxPath = path.join(homeDir, '.claude', 'projects');
|
|
129
|
+
|
|
130
|
+
// Check if running in WSL
|
|
131
|
+
if (isWSL()) {
|
|
132
|
+
// Try Windows user directories
|
|
133
|
+
const windowsUsers = '/mnt/c/Users';
|
|
134
|
+
if (fs.existsSync(windowsUsers)) {
|
|
135
|
+
// Try current username first
|
|
136
|
+
const username = process.env.USER || '';
|
|
137
|
+
const windowsPath = path.join(windowsUsers, username, '.claude', 'projects');
|
|
138
|
+
if (fs.existsSync(windowsPath)) {
|
|
139
|
+
return windowsPath;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Try to find any user with .claude folder
|
|
143
|
+
try {
|
|
144
|
+
const users = fs.readdirSync(windowsUsers, { withFileTypes: true });
|
|
145
|
+
for (const user of users) {
|
|
146
|
+
if (user.isDirectory() && !user.name.startsWith('Public') && !user.name.startsWith('Default')) {
|
|
147
|
+
const potentialPath = path.join(windowsUsers, user.name, '.claude', 'projects');
|
|
148
|
+
if (fs.existsSync(potentialPath)) {
|
|
149
|
+
return potentialPath;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// Ignore errors reading Windows users
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return linuxPath;
|
|
116
160
|
}
|
|
117
161
|
|
|
118
162
|
/**
|
|
@@ -136,12 +180,18 @@ function listProjects() {
|
|
|
136
180
|
const sessionsPath = path.join(projectPath, 'sessions');
|
|
137
181
|
let sessionCount = 0;
|
|
138
182
|
|
|
183
|
+
// Check for sessions in sessions/ subdirectory (new structure)
|
|
139
184
|
if (fs.existsSync(sessionsPath)) {
|
|
140
185
|
const sessions = fs.readdirSync(sessionsPath)
|
|
141
186
|
.filter(f => f.endsWith('.json') || f.endsWith('.jsonl'));
|
|
142
|
-
sessionCount
|
|
187
|
+
sessionCount += sessions.length;
|
|
143
188
|
}
|
|
144
189
|
|
|
190
|
+
// Also check for .jsonl files directly in project directory (old structure)
|
|
191
|
+
const directJsonl = fs.readdirSync(projectPath)
|
|
192
|
+
.filter(f => f.endsWith('.jsonl') && !f.startsWith('.'));
|
|
193
|
+
sessionCount += directJsonl.length;
|
|
194
|
+
|
|
145
195
|
return {
|
|
146
196
|
name: entry.name,
|
|
147
197
|
path: projectPath,
|
package/package.json
CHANGED
package/scripts/analyzer.py
CHANGED
|
@@ -565,6 +565,70 @@ def quick_analyze(commands: List[str], verbose: bool = False) -> Dict[str, Any]:
|
|
|
565
565
|
return summary
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
def analyze_commands(commands: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
569
|
+
"""
|
|
570
|
+
Analyze a list of command dictionaries for the pipeline.
|
|
571
|
+
|
|
572
|
+
This is the interface expected by main.py.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
commands: List of command dictionaries with 'command' key
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Dictionary with 'categories', 'commands', 'statistics' keys
|
|
579
|
+
"""
|
|
580
|
+
# Extract command strings from dictionaries
|
|
581
|
+
cmd_strings = [
|
|
582
|
+
cmd.get('command', '') or cmd.get('raw', '')
|
|
583
|
+
for cmd in commands
|
|
584
|
+
if cmd.get('command') or cmd.get('raw')
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
if not cmd_strings:
|
|
588
|
+
return {
|
|
589
|
+
'categories': {},
|
|
590
|
+
'commands': [],
|
|
591
|
+
'statistics': {},
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
result = analyze_session(cmd_strings)
|
|
595
|
+
|
|
596
|
+
# Build analyzed command list with parsed info
|
|
597
|
+
analyzed_commands = []
|
|
598
|
+
for cmd_dict in commands:
|
|
599
|
+
cmd_str = cmd_dict.get('command', '') or cmd_dict.get('raw', '')
|
|
600
|
+
if cmd_str:
|
|
601
|
+
parsed = parse_command(cmd_str)
|
|
602
|
+
analyzed_commands.append({
|
|
603
|
+
'command': cmd_str,
|
|
604
|
+
'description': cmd_dict.get('description', ''),
|
|
605
|
+
'output': cmd_dict.get('output', ''),
|
|
606
|
+
'base_command': parsed.base_command,
|
|
607
|
+
'flags': parsed.flags,
|
|
608
|
+
'args': parsed.args,
|
|
609
|
+
'complexity': score_complexity(parsed),
|
|
610
|
+
'category': assign_category(parsed),
|
|
611
|
+
'success': cmd_dict.get('success', True),
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
# Group by category
|
|
615
|
+
categories = {}
|
|
616
|
+
for cmd in analyzed_commands:
|
|
617
|
+
cat = cmd['category']
|
|
618
|
+
if cat not in categories:
|
|
619
|
+
categories[cat] = []
|
|
620
|
+
categories[cat].append(cmd)
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
'categories': categories,
|
|
624
|
+
'commands': analyzed_commands,
|
|
625
|
+
'statistics': result.statistics,
|
|
626
|
+
'category_breakdown': result.category_breakdown,
|
|
627
|
+
'complexity_distribution': result.complexity_distribution,
|
|
628
|
+
'top_commands': result.top_commands,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
568
632
|
if __name__ == "__main__":
|
|
569
633
|
# Example usage and testing
|
|
570
634
|
test_commands = [
|
package/scripts/extractor.py
CHANGED
|
@@ -348,6 +348,43 @@ class JSONLExtractor:
|
|
|
348
348
|
return all_commands
|
|
349
349
|
|
|
350
350
|
|
|
351
|
+
def extract_commands(entries: list[dict]) -> list[dict]:
|
|
352
|
+
"""
|
|
353
|
+
Extract bash commands from a list of session entries.
|
|
354
|
+
|
|
355
|
+
This is the interface expected by main.py for pipeline processing.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
entries: List of parsed JSON entries from session files
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
List of command dictionaries with 'command', 'description', 'output' keys
|
|
362
|
+
"""
|
|
363
|
+
extractor = JSONLExtractor()
|
|
364
|
+
tool_uses: dict[str, dict] = {}
|
|
365
|
+
tool_results: dict[str, dict] = {}
|
|
366
|
+
sequence_counter = 0
|
|
367
|
+
|
|
368
|
+
for entry in entries:
|
|
369
|
+
extractor._process_entry(entry, tool_uses, tool_results, sequence_counter)
|
|
370
|
+
sequence_counter += 1
|
|
371
|
+
|
|
372
|
+
extracted = extractor._correlate_commands(tool_uses, tool_results)
|
|
373
|
+
|
|
374
|
+
# Convert ExtractedCommand objects to dicts for pipeline compatibility
|
|
375
|
+
return [
|
|
376
|
+
{
|
|
377
|
+
'command': cmd.command,
|
|
378
|
+
'description': cmd.description,
|
|
379
|
+
'output': cmd.output,
|
|
380
|
+
'timestamp': cmd.timestamp,
|
|
381
|
+
'success': cmd.success,
|
|
382
|
+
'exit_code': cmd.exit_code,
|
|
383
|
+
}
|
|
384
|
+
for cmd in extracted
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
|
|
351
388
|
def extract_commands_from_jsonl(file_path: str | Path) -> list[ExtractedCommand]:
|
|
352
389
|
"""
|
|
353
390
|
Convenience function to extract commands from a single JSONL file.
|
|
@@ -6,13 +6,14 @@ Generates a single self-contained HTML file with all CSS and JS inline.
|
|
|
6
6
|
No external dependencies - pure Python standard library.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from typing import Any
|
|
9
|
+
from typing import Any, List
|
|
10
10
|
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
11
12
|
import html
|
|
12
13
|
import json
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
def
|
|
16
|
+
def _generate_html_impl(analysis_result: dict[str, Any], quizzes: list[dict[str, Any]]) -> str:
|
|
16
17
|
"""
|
|
17
18
|
Generate complete HTML report from analysis results and quizzes.
|
|
18
19
|
|
|
@@ -1957,6 +1958,126 @@ def get_inline_js(quizzes: list[dict]) -> str:
|
|
|
1957
1958
|
'''
|
|
1958
1959
|
|
|
1959
1960
|
|
|
1961
|
+
def generate_html_files(
|
|
1962
|
+
commands: List[dict],
|
|
1963
|
+
analysis: dict,
|
|
1964
|
+
quizzes: list,
|
|
1965
|
+
output_dir: Path
|
|
1966
|
+
) -> List[Path]:
|
|
1967
|
+
"""
|
|
1968
|
+
Generate HTML files from commands, analysis and quizzes.
|
|
1969
|
+
|
|
1970
|
+
This is the interface expected by main.py for the pipeline.
|
|
1971
|
+
|
|
1972
|
+
Args:
|
|
1973
|
+
commands: List of command dictionaries
|
|
1974
|
+
analysis: Analysis dictionary from analyze_commands
|
|
1975
|
+
quizzes: List of quiz dictionaries
|
|
1976
|
+
output_dir: Output directory path
|
|
1977
|
+
|
|
1978
|
+
Returns:
|
|
1979
|
+
List of generated file paths
|
|
1980
|
+
"""
|
|
1981
|
+
output_dir = Path(output_dir)
|
|
1982
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1983
|
+
|
|
1984
|
+
# Build analysis_result in expected format for generate_html
|
|
1985
|
+
stats = analysis.get('statistics', {})
|
|
1986
|
+
categories = analysis.get('categories', {})
|
|
1987
|
+
analyzed_commands = analysis.get('commands', commands)
|
|
1988
|
+
|
|
1989
|
+
# Transform commands to expected format
|
|
1990
|
+
formatted_commands = []
|
|
1991
|
+
for cmd in analyzed_commands:
|
|
1992
|
+
# Convert flags to expected format (list of dicts with 'flag' and 'description')
|
|
1993
|
+
raw_flags = cmd.get('flags', [])
|
|
1994
|
+
formatted_flags = []
|
|
1995
|
+
for f in raw_flags:
|
|
1996
|
+
if isinstance(f, dict):
|
|
1997
|
+
formatted_flags.append(f)
|
|
1998
|
+
elif isinstance(f, str):
|
|
1999
|
+
formatted_flags.append({'flag': f, 'description': ''})
|
|
2000
|
+
|
|
2001
|
+
formatted_commands.append({
|
|
2002
|
+
'base_command': cmd.get('base_command', cmd.get('command', '').split()[0] if cmd.get('command') else ''),
|
|
2003
|
+
'full_command': cmd.get('command', ''),
|
|
2004
|
+
'category': cmd.get('category', 'Other'),
|
|
2005
|
+
'complexity': cmd.get('complexity', 1),
|
|
2006
|
+
'frequency': cmd.get('frequency', 1),
|
|
2007
|
+
'description': cmd.get('description', ''),
|
|
2008
|
+
'flags': formatted_flags,
|
|
2009
|
+
'is_new': False,
|
|
2010
|
+
})
|
|
2011
|
+
|
|
2012
|
+
analysis_result = {
|
|
2013
|
+
'stats': {
|
|
2014
|
+
'total_commands': stats.get('total_commands', len(commands)),
|
|
2015
|
+
'unique_commands': stats.get('unique_commands', len(commands)),
|
|
2016
|
+
'total_categories': len(categories),
|
|
2017
|
+
'complexity_avg': stats.get('average_complexity', 2),
|
|
2018
|
+
},
|
|
2019
|
+
'commands': formatted_commands,
|
|
2020
|
+
'categories': {cat: [c.get('command', '') for c in cmds] for cat, cmds in categories.items()},
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
# Transform quizzes to expected format for HTML generator
|
|
2024
|
+
# HTML generator expects: options as list of strings, correct_answer as int index
|
|
2025
|
+
formatted_quizzes = []
|
|
2026
|
+
for quiz in quizzes:
|
|
2027
|
+
options = quiz.get('options', [])
|
|
2028
|
+
|
|
2029
|
+
# Convert options from dicts to strings and find correct index
|
|
2030
|
+
option_texts = []
|
|
2031
|
+
correct_idx = 0
|
|
2032
|
+
for idx, opt in enumerate(options):
|
|
2033
|
+
if isinstance(opt, dict):
|
|
2034
|
+
option_texts.append(opt.get('text', ''))
|
|
2035
|
+
if opt.get('is_correct', False):
|
|
2036
|
+
correct_idx = idx
|
|
2037
|
+
else:
|
|
2038
|
+
option_texts.append(str(opt))
|
|
2039
|
+
|
|
2040
|
+
formatted_quizzes.append({
|
|
2041
|
+
'question': quiz.get('question', ''),
|
|
2042
|
+
'options': option_texts,
|
|
2043
|
+
'correct_answer': correct_idx,
|
|
2044
|
+
'explanation': quiz.get('explanation', ''),
|
|
2045
|
+
})
|
|
2046
|
+
|
|
2047
|
+
# Generate HTML
|
|
2048
|
+
html_content = _generate_html_impl(analysis_result, formatted_quizzes)
|
|
2049
|
+
|
|
2050
|
+
# Write to file
|
|
2051
|
+
index_file = output_dir / "index.html"
|
|
2052
|
+
with open(index_file, 'w', encoding='utf-8') as f:
|
|
2053
|
+
f.write(html_content)
|
|
2054
|
+
|
|
2055
|
+
return [index_file]
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def generate_html(
|
|
2059
|
+
commands_or_analysis: Any,
|
|
2060
|
+
analysis_or_quizzes: Any = None,
|
|
2061
|
+
quizzes: Any = None,
|
|
2062
|
+
output_dir: Any = None
|
|
2063
|
+
) -> Any:
|
|
2064
|
+
"""
|
|
2065
|
+
Wrapper that handles both original 2-param and main.py 4-param signatures.
|
|
2066
|
+
|
|
2067
|
+
Original: generate_html(analysis_result, quizzes) -> str
|
|
2068
|
+
Pipeline: generate_html(commands, analysis, quizzes, output_dir) -> List[Path]
|
|
2069
|
+
"""
|
|
2070
|
+
if output_dir is not None:
|
|
2071
|
+
# Called with 4 params from main.py pipeline
|
|
2072
|
+
return generate_html_files(commands_or_analysis, analysis_or_quizzes, quizzes, output_dir)
|
|
2073
|
+
elif quizzes is not None:
|
|
2074
|
+
# Called with 3 params (shouldn't happen but handle it)
|
|
2075
|
+
return generate_html_files(commands_or_analysis, analysis_or_quizzes, quizzes, Path('./output'))
|
|
2076
|
+
else:
|
|
2077
|
+
# Original 2-param call: generate_html(analysis_result, quizzes)
|
|
2078
|
+
return _generate_html_impl(commands_or_analysis, analysis_or_quizzes)
|
|
2079
|
+
|
|
2080
|
+
|
|
1960
2081
|
if __name__ == "__main__":
|
|
1961
2082
|
# Test with sample data
|
|
1962
2083
|
sample_analysis = {
|
package/scripts/main.py
CHANGED
|
@@ -22,7 +22,48 @@ if sys.version_info < (3, 8):
|
|
|
22
22
|
# Constants
|
|
23
23
|
DEFAULT_OUTPUT_DIR = "./bash-learner-output/"
|
|
24
24
|
MAX_UNIQUE_COMMANDS = 500
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_sessions_base_path() -> Path:
|
|
28
|
+
"""
|
|
29
|
+
Get the base path for Claude session files.
|
|
30
|
+
|
|
31
|
+
Handles WSL by checking both Linux and Windows paths.
|
|
32
|
+
"""
|
|
33
|
+
# Standard Linux/Mac path
|
|
34
|
+
linux_path = Path.home() / ".claude" / "projects"
|
|
35
|
+
|
|
36
|
+
# Check if we're in WSL
|
|
37
|
+
is_wsl = False
|
|
38
|
+
try:
|
|
39
|
+
with open("/proc/version", "r") as f:
|
|
40
|
+
is_wsl = "microsoft" in f.read().lower() or "wsl" in f.read().lower()
|
|
41
|
+
except (FileNotFoundError, PermissionError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
if is_wsl:
|
|
45
|
+
# Try to find Windows user directory
|
|
46
|
+
# Check common Windows user paths via /mnt/c/Users/
|
|
47
|
+
windows_users = Path("/mnt/c/Users")
|
|
48
|
+
if windows_users.exists():
|
|
49
|
+
# Try current username first
|
|
50
|
+
username = os.environ.get("USER", "")
|
|
51
|
+
windows_path = windows_users / username / ".claude" / "projects"
|
|
52
|
+
if windows_path.exists():
|
|
53
|
+
return windows_path
|
|
54
|
+
|
|
55
|
+
# Try to find any user with .claude folder
|
|
56
|
+
for user_dir in windows_users.iterdir():
|
|
57
|
+
if user_dir.is_dir() and not user_dir.name.startswith(("Public", "Default")):
|
|
58
|
+
potential_path = user_dir / ".claude" / "projects"
|
|
59
|
+
if potential_path.exists():
|
|
60
|
+
return potential_path
|
|
61
|
+
|
|
62
|
+
# Fall back to Linux path
|
|
63
|
+
return linux_path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
SESSIONS_BASE_PATH = get_sessions_base_path()
|
|
26
67
|
|
|
27
68
|
|
|
28
69
|
def get_session_metadata(session_path: Path) -> Dict:
|
|
@@ -74,7 +115,8 @@ def format_file_size(size_bytes: int) -> str:
|
|
|
74
115
|
|
|
75
116
|
def discover_sessions(
|
|
76
117
|
project_filter: Optional[str] = None,
|
|
77
|
-
limit: Optional[int] = None
|
|
118
|
+
limit: Optional[int] = None,
|
|
119
|
+
sessions_dir: Optional[Path] = None
|
|
78
120
|
) -> List[Dict]:
|
|
79
121
|
"""
|
|
80
122
|
Discover available Claude session files.
|
|
@@ -82,25 +124,35 @@ def discover_sessions(
|
|
|
82
124
|
Args:
|
|
83
125
|
project_filter: Optional filter for project path substring
|
|
84
126
|
limit: Maximum number of sessions to return
|
|
127
|
+
sessions_dir: Custom sessions directory (defaults to auto-detected)
|
|
85
128
|
|
|
86
129
|
Returns:
|
|
87
130
|
List of session metadata dictionaries, sorted by modification time (newest first)
|
|
88
131
|
"""
|
|
89
132
|
sessions = []
|
|
133
|
+
base_path = sessions_dir or SESSIONS_BASE_PATH
|
|
90
134
|
|
|
91
|
-
if not
|
|
135
|
+
if not base_path.exists():
|
|
92
136
|
return sessions
|
|
93
137
|
|
|
94
|
-
# Find all session files
|
|
95
|
-
|
|
138
|
+
# Find all session files - check both old and new directory structures
|
|
139
|
+
# New structure: projects/<hash>/sessions/*.jsonl
|
|
140
|
+
# Old structure: projects/<hash>/*.jsonl
|
|
141
|
+
for project_dir in base_path.iterdir():
|
|
96
142
|
if not project_dir.is_dir():
|
|
97
143
|
continue
|
|
98
144
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
# Check for sessions subdirectory (new structure)
|
|
146
|
+
sessions_subdir = project_dir / "sessions"
|
|
147
|
+
if sessions_subdir.exists():
|
|
148
|
+
for session_file in sessions_subdir.glob("*.jsonl"):
|
|
149
|
+
metadata = get_session_metadata(session_file)
|
|
150
|
+
if project_filter and project_filter.lower() not in str(session_file).lower():
|
|
151
|
+
continue
|
|
152
|
+
sessions.append(metadata)
|
|
102
153
|
|
|
103
|
-
for
|
|
154
|
+
# Also check for .jsonl files directly in project dir (old structure)
|
|
155
|
+
for session_file in project_dir.glob("*.jsonl"):
|
|
104
156
|
metadata = get_session_metadata(session_file)
|
|
105
157
|
|
|
106
158
|
# Apply project filter if specified
|
|
@@ -120,19 +172,23 @@ def discover_sessions(
|
|
|
120
172
|
return sessions
|
|
121
173
|
|
|
122
174
|
|
|
123
|
-
def list_sessions(project_filter: Optional[str] = None) -> None:
|
|
175
|
+
def list_sessions(project_filter: Optional[str] = None, sessions_dir: Optional[Path] = None) -> None:
|
|
124
176
|
"""
|
|
125
177
|
Display available sessions in a formatted table.
|
|
126
178
|
|
|
127
179
|
Args:
|
|
128
180
|
project_filter: Optional filter for project path substring
|
|
181
|
+
sessions_dir: Custom sessions directory
|
|
129
182
|
"""
|
|
130
|
-
|
|
183
|
+
base_path = sessions_dir or SESSIONS_BASE_PATH
|
|
184
|
+
sessions = discover_sessions(project_filter=project_filter, sessions_dir=sessions_dir)
|
|
131
185
|
|
|
132
186
|
if not sessions:
|
|
133
187
|
print("\nNo session files found.")
|
|
134
|
-
print(f"\
|
|
188
|
+
print(f"\nSearched in: {base_path}")
|
|
189
|
+
print(f"\nExpected structure: <sessions-dir>/<project-hash>/*.jsonl")
|
|
135
190
|
print("\nMake sure you have Claude session data available.")
|
|
191
|
+
print("\nTip: On WSL, try using -s /mnt/c/Users/<username>/.claude/projects/")
|
|
136
192
|
return
|
|
137
193
|
|
|
138
194
|
print(f"\n{'='*80}")
|
|
@@ -371,6 +427,12 @@ Examples:
|
|
|
371
427
|
help='Enable verbose output'
|
|
372
428
|
)
|
|
373
429
|
|
|
430
|
+
parser.add_argument(
|
|
431
|
+
'-s', '--sessions-dir',
|
|
432
|
+
type=str,
|
|
433
|
+
help=f'Sessions directory (default: auto-detected, currently {SESSIONS_BASE_PATH})'
|
|
434
|
+
)
|
|
435
|
+
|
|
374
436
|
return parser.parse_args()
|
|
375
437
|
|
|
376
438
|
|
|
@@ -383,9 +445,12 @@ def main() -> int:
|
|
|
383
445
|
"""
|
|
384
446
|
args = parse_arguments()
|
|
385
447
|
|
|
448
|
+
# Handle custom sessions directory
|
|
449
|
+
custom_sessions_dir = Path(args.sessions_dir) if args.sessions_dir else None
|
|
450
|
+
|
|
386
451
|
# Handle --list
|
|
387
452
|
if args.list:
|
|
388
|
-
list_sessions(project_filter=args.project)
|
|
453
|
+
list_sessions(project_filter=args.project, sessions_dir=custom_sessions_dir)
|
|
389
454
|
return 0
|
|
390
455
|
|
|
391
456
|
# Determine which sessions to process
|
|
@@ -404,16 +469,20 @@ def main() -> int:
|
|
|
404
469
|
|
|
405
470
|
else:
|
|
406
471
|
# Discover and select sessions
|
|
472
|
+
base_path = custom_sessions_dir or SESSIONS_BASE_PATH
|
|
407
473
|
sessions = discover_sessions(
|
|
408
474
|
project_filter=args.project,
|
|
409
|
-
limit=args.sessions
|
|
475
|
+
limit=args.sessions,
|
|
476
|
+
sessions_dir=custom_sessions_dir
|
|
410
477
|
)
|
|
411
478
|
|
|
412
479
|
if not sessions:
|
|
413
480
|
print("\nNo session files found.")
|
|
414
|
-
print(f"\
|
|
481
|
+
print(f"\nSearched in: {base_path}")
|
|
482
|
+
print(f"\nExpected structure: <sessions-dir>/<project-hash>/*.jsonl")
|
|
415
483
|
print("\nTo create session data, use Claude Code and your sessions will be stored automatically.")
|
|
416
484
|
print("\nUse --list to see available sessions once you have some.")
|
|
485
|
+
print("\nTip: On WSL, try using -s /mnt/c/Users/<username>/.claude/projects/")
|
|
417
486
|
return 1
|
|
418
487
|
|
|
419
488
|
sessions_to_process = sessions
|
package/scripts/parser.py
CHANGED
|
@@ -574,19 +574,56 @@ def parse_command(
|
|
|
574
574
|
|
|
575
575
|
|
|
576
576
|
def parse_commands(
|
|
577
|
-
commands: list
|
|
578
|
-
) -> list[
|
|
577
|
+
commands: list
|
|
578
|
+
) -> list[dict]:
|
|
579
579
|
"""
|
|
580
580
|
Convenience function to parse multiple bash commands.
|
|
581
581
|
|
|
582
582
|
Args:
|
|
583
|
-
commands: List of (command, description, output) tuples
|
|
583
|
+
commands: List of (command, description, output) tuples OR
|
|
584
|
+
List of dicts with 'command', 'description', 'output' keys
|
|
584
585
|
|
|
585
586
|
Returns:
|
|
586
|
-
List of
|
|
587
|
+
List of parsed command dictionaries
|
|
587
588
|
"""
|
|
588
589
|
parser = BashParser()
|
|
589
|
-
|
|
590
|
+
|
|
591
|
+
# Normalize input to tuples
|
|
592
|
+
normalized = []
|
|
593
|
+
for item in commands:
|
|
594
|
+
if isinstance(item, dict):
|
|
595
|
+
cmd = item.get('command', '')
|
|
596
|
+
desc = item.get('description', '')
|
|
597
|
+
output = item.get('output', '')
|
|
598
|
+
normalized.append((cmd, desc, output))
|
|
599
|
+
elif isinstance(item, (tuple, list)) and len(item) >= 3:
|
|
600
|
+
normalized.append((item[0], item[1], item[2]))
|
|
601
|
+
elif isinstance(item, str):
|
|
602
|
+
normalized.append((item, '', ''))
|
|
603
|
+
else:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
parsed_objs = parser.parse_batch(normalized)
|
|
607
|
+
|
|
608
|
+
# Convert ParsedCommand objects to dicts for pipeline compatibility
|
|
609
|
+
return [
|
|
610
|
+
{
|
|
611
|
+
'command': p.raw,
|
|
612
|
+
'raw': p.raw,
|
|
613
|
+
'base_command': p.base_commands[0] if p.base_commands else '',
|
|
614
|
+
'base_commands': p.base_commands,
|
|
615
|
+
'flags': p.flags,
|
|
616
|
+
'args': p.arguments,
|
|
617
|
+
'pipes': p.pipes,
|
|
618
|
+
'redirects': p.redirects,
|
|
619
|
+
'category': p.category.value if p.category else 'unknown',
|
|
620
|
+
'complexity': p.complexity_score,
|
|
621
|
+
'description': p.description,
|
|
622
|
+
'output': p.output,
|
|
623
|
+
'is_compound': len(p.base_commands) > 1 or len(p.pipes) > 0,
|
|
624
|
+
}
|
|
625
|
+
for p in parsed_objs
|
|
626
|
+
]
|
|
590
627
|
|
|
591
628
|
|
|
592
629
|
if __name__ == "__main__":
|
|
@@ -993,6 +993,37 @@ def create_quiz(
|
|
|
993
993
|
)
|
|
994
994
|
|
|
995
995
|
|
|
996
|
+
def generate_quizzes(
|
|
997
|
+
commands: list[dict],
|
|
998
|
+
analysis: dict,
|
|
999
|
+
question_count: int = 20
|
|
1000
|
+
) -> list[dict]:
|
|
1001
|
+
"""
|
|
1002
|
+
Generate quizzes from commands and analysis for the pipeline.
|
|
1003
|
+
|
|
1004
|
+
This is the interface expected by main.py.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
commands: List of command dictionaries
|
|
1008
|
+
analysis: Analysis dictionary from analyze_commands
|
|
1009
|
+
question_count: Target number of questions
|
|
1010
|
+
|
|
1011
|
+
Returns:
|
|
1012
|
+
List of quiz question dictionaries
|
|
1013
|
+
"""
|
|
1014
|
+
# Get analyzed commands from analysis if available, otherwise use raw commands
|
|
1015
|
+
analyzed_commands = analysis.get('commands', commands)
|
|
1016
|
+
|
|
1017
|
+
if not analyzed_commands:
|
|
1018
|
+
return []
|
|
1019
|
+
|
|
1020
|
+
# Generate quiz questions
|
|
1021
|
+
questions = generate_quiz_set(analyzed_commands, question_count)
|
|
1022
|
+
|
|
1023
|
+
# Convert QuizQuestion objects to dictionaries using the built-in method
|
|
1024
|
+
return [q.to_dict() for q in questions]
|
|
1025
|
+
|
|
1026
|
+
|
|
996
1027
|
# =============================================================================
|
|
997
1028
|
# Main entry point for testing
|
|
998
1029
|
# =============================================================================
|