learn_bash_from_session_data 1.0.1 → 1.0.3
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 +12 -5
- package/scripts/html_generator.py +74 -12
- package/scripts/main.py +84 -15
- package/scripts/quiz_generator.py +182 -6
- package/test-output/index.html +0 -3954
- package/test-output/summary.json +0 -19
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
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "learn_bash_from_session_data",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Learn bash from your Claude Code sessions - extracts commands and generates interactive HTML lessons",
|
|
5
5
|
"main": "bin/learn-bash.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"learn-bash": "
|
|
8
|
-
"bash-learner": "
|
|
7
|
+
"learn-bash": "bin/learn-bash.js",
|
|
8
|
+
"bash-learner": "bin/learn-bash.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node tests/test-runner.js"
|
|
12
12
|
},
|
|
13
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"bash",
|
|
15
|
+
"learning",
|
|
16
|
+
"claude",
|
|
17
|
+
"cli",
|
|
18
|
+
"terminal",
|
|
19
|
+
"shell"
|
|
20
|
+
],
|
|
14
21
|
"author": "",
|
|
15
22
|
"license": "MIT",
|
|
16
23
|
"engines": {
|
|
@@ -18,6 +25,6 @@
|
|
|
18
25
|
},
|
|
19
26
|
"repository": {
|
|
20
27
|
"type": "git",
|
|
21
|
-
"url": "https://github.com/bjpl/learn_bash_from_session_data"
|
|
28
|
+
"url": "git+https://github.com/bjpl/learn_bash_from_session_data.git"
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -128,16 +128,37 @@ def render_overview_tab(stats: dict[str, Any], commands: list[dict], categories:
|
|
|
128
128
|
intermediate_pct = (complexity_dist.get("intermediate", 0) / total_for_pct) * 100
|
|
129
129
|
advanced_pct = (complexity_dist.get("advanced", 0) / total_for_pct) * 100
|
|
130
130
|
|
|
131
|
-
# Top 10 commands by frequency
|
|
132
|
-
|
|
131
|
+
# Top 10 commands by frequency - use pre-computed data if available
|
|
132
|
+
top_commands_data = stats.get("top_commands", [])
|
|
133
133
|
top_commands_html = ""
|
|
134
|
-
max_freq = sorted_commands[0].get("frequency", 1) if sorted_commands else 1
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
if top_commands_data:
|
|
136
|
+
max_freq = top_commands_data[0].get("count", 1) if top_commands_data else 1
|
|
137
|
+
for item in top_commands_data[:10]:
|
|
138
|
+
cmd_str = item.get("command", "")
|
|
139
|
+
freq = item.get("count", 1)
|
|
140
|
+
bar_width = (freq / max_freq) * 100
|
|
141
|
+
# Extract base command from full command
|
|
142
|
+
cmd_name = html.escape(cmd_str.split()[0] if cmd_str else "unknown")
|
|
143
|
+
top_commands_html += f'''
|
|
144
|
+
<div class="top-command-item">
|
|
145
|
+
<div class="top-command-name">
|
|
146
|
+
<code class="cmd">{cmd_name}</code>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="top-command-bar-container">
|
|
149
|
+
<div class="top-command-bar" style="width: {bar_width}%"></div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="top-command-count">{freq}</div>
|
|
152
|
+
</div>'''
|
|
153
|
+
else:
|
|
154
|
+
# Fallback to sorting commands by frequency
|
|
155
|
+
sorted_commands = sorted(commands, key=lambda x: x.get("frequency", 0), reverse=True)[:10]
|
|
156
|
+
max_freq = sorted_commands[0].get("frequency", 1) if sorted_commands else 1
|
|
157
|
+
for cmd in sorted_commands:
|
|
158
|
+
freq = cmd.get("frequency", 0)
|
|
159
|
+
bar_width = (freq / max_freq) * 100
|
|
160
|
+
cmd_name = html.escape(cmd.get("base_command", "unknown"))
|
|
161
|
+
top_commands_html += f'''
|
|
141
162
|
<div class="top-command-item">
|
|
142
163
|
<div class="top-command-name">
|
|
143
164
|
<code class="cmd">{cmd_name}</code>
|
|
@@ -1986,6 +2007,23 @@ def generate_html_files(
|
|
|
1986
2007
|
categories = analysis.get('categories', {})
|
|
1987
2008
|
analyzed_commands = analysis.get('commands', commands)
|
|
1988
2009
|
|
|
2010
|
+
# Build frequency map from top_commands
|
|
2011
|
+
top_commands_data = analysis.get('top_commands', [])
|
|
2012
|
+
frequency_map = {}
|
|
2013
|
+
for item in top_commands_data:
|
|
2014
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
2015
|
+
cmd_str, count = item[0], item[1]
|
|
2016
|
+
frequency_map[cmd_str] = count
|
|
2017
|
+
|
|
2018
|
+
# Map complexity scores (1-5) to string labels for CSS
|
|
2019
|
+
def complexity_to_label(score):
|
|
2020
|
+
if score <= 2:
|
|
2021
|
+
return 'simple'
|
|
2022
|
+
elif score == 3:
|
|
2023
|
+
return 'intermediate'
|
|
2024
|
+
else:
|
|
2025
|
+
return 'advanced'
|
|
2026
|
+
|
|
1989
2027
|
# Transform commands to expected format
|
|
1990
2028
|
formatted_commands = []
|
|
1991
2029
|
for cmd in analyzed_commands:
|
|
@@ -1998,23 +2036,47 @@ def generate_html_files(
|
|
|
1998
2036
|
elif isinstance(f, str):
|
|
1999
2037
|
formatted_flags.append({'flag': f, 'description': ''})
|
|
2000
2038
|
|
|
2039
|
+
cmd_str = cmd.get('command', '')
|
|
2040
|
+
complexity_score = cmd.get('complexity', 1)
|
|
2041
|
+
|
|
2001
2042
|
formatted_commands.append({
|
|
2002
|
-
'base_command': cmd.get('base_command',
|
|
2003
|
-
'full_command':
|
|
2043
|
+
'base_command': cmd.get('base_command', cmd_str.split()[0] if cmd_str else ''),
|
|
2044
|
+
'full_command': cmd_str,
|
|
2004
2045
|
'category': cmd.get('category', 'Other'),
|
|
2005
|
-
'complexity':
|
|
2006
|
-
'
|
|
2046
|
+
'complexity': complexity_to_label(complexity_score),
|
|
2047
|
+
'complexity_score': complexity_score,
|
|
2048
|
+
'frequency': frequency_map.get(cmd_str, 1),
|
|
2007
2049
|
'description': cmd.get('description', ''),
|
|
2008
2050
|
'flags': formatted_flags,
|
|
2009
2051
|
'is_new': False,
|
|
2010
2052
|
})
|
|
2011
2053
|
|
|
2054
|
+
# Transform complexity distribution from numeric keys to string labels
|
|
2055
|
+
raw_complexity = stats.get('complexity_distribution', {})
|
|
2056
|
+
complexity_distribution = {
|
|
2057
|
+
'simple': raw_complexity.get(1, 0) + raw_complexity.get(2, 0),
|
|
2058
|
+
'intermediate': raw_complexity.get(3, 0),
|
|
2059
|
+
'advanced': raw_complexity.get(4, 0) + raw_complexity.get(5, 0),
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
# Build top commands list with proper frequencies
|
|
2063
|
+
top_10_commands = []
|
|
2064
|
+
for item in top_commands_data[:10]:
|
|
2065
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
2066
|
+
top_10_commands.append({
|
|
2067
|
+
'command': item[0],
|
|
2068
|
+
'count': item[1]
|
|
2069
|
+
})
|
|
2070
|
+
|
|
2012
2071
|
analysis_result = {
|
|
2013
2072
|
'stats': {
|
|
2014
2073
|
'total_commands': stats.get('total_commands', len(commands)),
|
|
2015
2074
|
'unique_commands': stats.get('unique_commands', len(commands)),
|
|
2075
|
+
'unique_utilities': stats.get('unique_base_commands', 0),
|
|
2016
2076
|
'total_categories': len(categories),
|
|
2017
2077
|
'complexity_avg': stats.get('average_complexity', 2),
|
|
2078
|
+
'complexity_distribution': complexity_distribution,
|
|
2079
|
+
'top_commands': top_10_commands, # Pre-computed top commands with frequencies
|
|
2018
2080
|
},
|
|
2019
2081
|
'commands': formatted_commands,
|
|
2020
2082
|
'categories': {cat: [c.get('command', '') for c in cmds] for cat, cmds in categories.items()},
|
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
|
|
@@ -392,6 +392,170 @@ def _get_related_commands(cmd: str) -> list[str]:
|
|
|
392
392
|
return []
|
|
393
393
|
|
|
394
394
|
|
|
395
|
+
def _generate_bash_description(cmd_string: str) -> str:
|
|
396
|
+
"""
|
|
397
|
+
Generate an educational description focusing on bash concepts.
|
|
398
|
+
|
|
399
|
+
Explains what each part of the command does from a bash perspective.
|
|
400
|
+
"""
|
|
401
|
+
if not cmd_string:
|
|
402
|
+
return "Runs a command"
|
|
403
|
+
|
|
404
|
+
parts = []
|
|
405
|
+
|
|
406
|
+
# Check for command chaining
|
|
407
|
+
if ' && ' in cmd_string:
|
|
408
|
+
commands = cmd_string.split(' && ')
|
|
409
|
+
for i, cmd in enumerate(commands):
|
|
410
|
+
base = cmd.strip().split()[0] if cmd.strip() else ''
|
|
411
|
+
if i == 0:
|
|
412
|
+
parts.append(_describe_single_command(cmd.strip()))
|
|
413
|
+
else:
|
|
414
|
+
parts.append(f"then {_describe_single_command(cmd.strip())}")
|
|
415
|
+
return ', '.join(parts)
|
|
416
|
+
|
|
417
|
+
if ' || ' in cmd_string:
|
|
418
|
+
commands = cmd_string.split(' || ')
|
|
419
|
+
parts.append(_describe_single_command(commands[0].strip()))
|
|
420
|
+
parts.append(f"or if that fails, {_describe_single_command(commands[1].strip())}")
|
|
421
|
+
return ', '.join(parts)
|
|
422
|
+
|
|
423
|
+
if ' | ' in cmd_string:
|
|
424
|
+
commands = cmd_string.split(' | ')
|
|
425
|
+
parts.append(_describe_single_command(commands[0].strip()))
|
|
426
|
+
for cmd in commands[1:]:
|
|
427
|
+
parts.append(f"pipes output to {_describe_single_command(cmd.strip())}")
|
|
428
|
+
return ', '.join(parts)
|
|
429
|
+
|
|
430
|
+
return _describe_single_command(cmd_string)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _describe_single_command(cmd: str) -> str:
|
|
434
|
+
"""Generate description for a single command (no pipes/chains)."""
|
|
435
|
+
if not cmd:
|
|
436
|
+
return "runs a command"
|
|
437
|
+
|
|
438
|
+
tokens = cmd.split()
|
|
439
|
+
base_cmd = tokens[0] if tokens else ''
|
|
440
|
+
|
|
441
|
+
# Common command descriptions with bash focus
|
|
442
|
+
descriptions = {
|
|
443
|
+
'cd': lambda args: f"changes directory to {args[0] if args else 'specified path'}",
|
|
444
|
+
'ls': lambda args: f"lists {'files in ' + args[0] if args else 'directory contents'}",
|
|
445
|
+
'mkdir': lambda args: f"creates directory {args[0] if args else ''}",
|
|
446
|
+
'rm': lambda args: f"removes {args[0] if args else 'files'}",
|
|
447
|
+
'cp': lambda args: f"copies files{' to ' + args[-1] if len(args) > 1 else ''}",
|
|
448
|
+
'mv': lambda args: f"moves/renames files{' to ' + args[-1] if len(args) > 1 else ''}",
|
|
449
|
+
'cat': lambda args: f"displays contents of {args[0] if args else 'file'}",
|
|
450
|
+
'echo': lambda args: f"prints {'text' if not args else repr(' '.join(args)[:30])}",
|
|
451
|
+
'grep': lambda args: f"searches for pattern in {'files' if len(args) > 1 else 'input'}",
|
|
452
|
+
'find': lambda args: f"finds files{' in ' + args[0] if args else ''} matching criteria",
|
|
453
|
+
'git': lambda args: f"runs git {args[0] if args else 'command'}" + _describe_git_subcommand(args),
|
|
454
|
+
'python': lambda args: "executes Python script" + (' from heredoc' if '<<' in cmd else ''),
|
|
455
|
+
'python3': lambda args: "executes Python 3 script" + (' from heredoc' if '<<' in cmd else ''),
|
|
456
|
+
'npm': lambda args: f"runs npm {args[0] if args else 'command'}",
|
|
457
|
+
'pip': lambda args: f"runs pip {args[0] if args else 'command'}",
|
|
458
|
+
'docker': lambda args: f"runs docker {args[0] if args else 'command'}",
|
|
459
|
+
'chmod': lambda args: f"changes permissions{' to ' + args[0] if args else ''}",
|
|
460
|
+
'chown': lambda args: "changes file ownership",
|
|
461
|
+
'curl': lambda args: "fetches URL content",
|
|
462
|
+
'wget': lambda args: "downloads file from URL",
|
|
463
|
+
'tar': lambda args: "archives/extracts tar files",
|
|
464
|
+
'ssh': lambda args: f"connects via SSH{' to ' + args[0] if args else ''}",
|
|
465
|
+
'sudo': lambda args: f"runs as superuser: {_describe_single_command(' '.join(args))}",
|
|
466
|
+
'export': lambda args: f"sets environment variable {args[0].split('=')[0] if args else ''}",
|
|
467
|
+
'source': lambda args: f"loads {args[0] if args else 'script'} into current shell",
|
|
468
|
+
'.': lambda args: f"loads {args[0] if args else 'script'} into current shell",
|
|
469
|
+
'touch': lambda args: f"creates/updates timestamp of {args[0] if args else 'file'}",
|
|
470
|
+
'head': lambda args: f"shows first lines of {args[-1] if args else 'file'}",
|
|
471
|
+
'tail': lambda args: f"shows last lines of {args[-1] if args else 'file'}",
|
|
472
|
+
'sort': lambda args: "sorts input lines",
|
|
473
|
+
'uniq': lambda args: "filters duplicate lines",
|
|
474
|
+
'wc': lambda args: "counts lines/words/bytes",
|
|
475
|
+
'awk': lambda args: "processes text with patterns",
|
|
476
|
+
'sed': lambda args: "transforms text with patterns",
|
|
477
|
+
'xargs': lambda args: "builds commands from input",
|
|
478
|
+
'tee': lambda args: "splits output to file and stdout",
|
|
479
|
+
'jq': lambda args: "processes JSON data",
|
|
480
|
+
'less': lambda args: f"pages through {args[0] if args else 'input'}",
|
|
481
|
+
'more': lambda args: f"pages through {args[0] if args else 'input'}",
|
|
482
|
+
'node': lambda args: "executes Node.js script",
|
|
483
|
+
'npx': lambda args: f"runs npm package {args[0] if args else 'command'}",
|
|
484
|
+
'make': lambda args: f"runs make {args[0] if args else 'target'}",
|
|
485
|
+
'cmake': lambda args: "configures CMake build",
|
|
486
|
+
'cargo': lambda args: f"runs Cargo {args[0] if args else 'command'}",
|
|
487
|
+
'rustc': lambda args: "compiles Rust code",
|
|
488
|
+
'go': lambda args: f"runs Go {args[0] if args else 'command'}",
|
|
489
|
+
'java': lambda args: "runs Java program",
|
|
490
|
+
'javac': lambda args: "compiles Java source",
|
|
491
|
+
'gcc': lambda args: "compiles C/C++ code",
|
|
492
|
+
'clang': lambda args: "compiles code with Clang",
|
|
493
|
+
'vim': lambda args: f"edits {args[0] if args else 'file'} in Vim",
|
|
494
|
+
'nano': lambda args: f"edits {args[0] if args else 'file'} in nano",
|
|
495
|
+
'emacs': lambda args: f"edits {args[0] if args else 'file'} in Emacs",
|
|
496
|
+
'which': lambda args: f"locates {args[0] if args else 'command'} executable",
|
|
497
|
+
'whereis': lambda args: f"finds {args[0] if args else 'command'} locations",
|
|
498
|
+
'man': lambda args: f"shows manual for {args[0] if args else 'command'}",
|
|
499
|
+
'pwd': lambda args: "prints current working directory",
|
|
500
|
+
'whoami': lambda args: "prints current username",
|
|
501
|
+
'date': lambda args: "displays current date/time",
|
|
502
|
+
'env': lambda args: "displays environment variables",
|
|
503
|
+
'set': lambda args: "sets shell options",
|
|
504
|
+
'unset': lambda args: f"removes variable {args[0] if args else ''}",
|
|
505
|
+
'read': lambda args: "reads input into variable",
|
|
506
|
+
'test': lambda args: "evaluates conditional expression",
|
|
507
|
+
'[': lambda args: "evaluates conditional expression",
|
|
508
|
+
'if': lambda args: "conditional statement",
|
|
509
|
+
'for': lambda args: "loop over items",
|
|
510
|
+
'while': lambda args: "loop while condition true",
|
|
511
|
+
'case': lambda args: "pattern matching statement",
|
|
512
|
+
'start': lambda args: f"opens {args[0] if args else 'file/URL'} (Windows)",
|
|
513
|
+
'open': lambda args: f"opens {args[0] if args else 'file/URL'} (macOS)",
|
|
514
|
+
'xdg-open': lambda args: f"opens {args[0] if args else 'file/URL'} (Linux)",
|
|
515
|
+
'code': lambda args: f"opens {args[0] if args else 'path'} in VS Code",
|
|
516
|
+
'claude': lambda args: f"runs Claude CLI {args[0] if args else 'command'}",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# Get args (skip flags)
|
|
520
|
+
args = [t for t in tokens[1:] if not t.startswith('-')]
|
|
521
|
+
|
|
522
|
+
if base_cmd in descriptions:
|
|
523
|
+
return descriptions[base_cmd](args)
|
|
524
|
+
|
|
525
|
+
# Default description
|
|
526
|
+
return f"executes {base_cmd} command"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _describe_git_subcommand(args: list) -> str:
|
|
530
|
+
"""Describe git subcommands in detail."""
|
|
531
|
+
if not args:
|
|
532
|
+
return ""
|
|
533
|
+
|
|
534
|
+
subcommand = args[0]
|
|
535
|
+
git_descriptions = {
|
|
536
|
+
'init': ' to initialize a new repository',
|
|
537
|
+
'clone': ' to copy a remote repository',
|
|
538
|
+
'add': ' to stage changes',
|
|
539
|
+
'commit': ' to save staged changes',
|
|
540
|
+
'push': ' to upload commits to remote',
|
|
541
|
+
'pull': ' to download and merge remote changes',
|
|
542
|
+
'fetch': ' to download remote changes',
|
|
543
|
+
'merge': ' to combine branches',
|
|
544
|
+
'rebase': ' to replay commits on new base',
|
|
545
|
+
'checkout': ' to switch branches or restore files',
|
|
546
|
+
'branch': ' to manage branches',
|
|
547
|
+
'status': ' to show working tree status',
|
|
548
|
+
'log': ' to show commit history',
|
|
549
|
+
'diff': ' to show changes',
|
|
550
|
+
'stash': ' to temporarily store changes',
|
|
551
|
+
'reset': ' to undo changes',
|
|
552
|
+
'remote': ' to manage remote connections',
|
|
553
|
+
'tag': ' to manage tags',
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return git_descriptions.get(subcommand, '')
|
|
557
|
+
|
|
558
|
+
|
|
395
559
|
def _parse_command(cmd_string: str) -> dict:
|
|
396
560
|
"""Parse a command string into components."""
|
|
397
561
|
parts = cmd_string.strip().split()
|
|
@@ -489,18 +653,23 @@ def generate_what_does_quiz(
|
|
|
489
653
|
parsed = _parse_command(cmd_string)
|
|
490
654
|
base_cmd = parsed["base"]
|
|
491
655
|
|
|
492
|
-
# Build the correct description
|
|
656
|
+
# Build the correct description using educational bash-focused generator
|
|
493
657
|
correct_desc = description
|
|
494
658
|
if not correct_desc:
|
|
495
|
-
#
|
|
659
|
+
# Use the educational bash description generator
|
|
660
|
+
correct_desc = _generate_bash_description(cmd_string)
|
|
661
|
+
# Capitalize first letter for consistent formatting
|
|
662
|
+
if correct_desc:
|
|
663
|
+
correct_desc = correct_desc[0].upper() + correct_desc[1:]
|
|
664
|
+
|
|
665
|
+
# Add flag details if available
|
|
496
666
|
flag_descs = []
|
|
497
667
|
for flag in parsed["flags"]:
|
|
498
668
|
fd = _get_flag_description(base_cmd, flag)
|
|
499
669
|
if fd:
|
|
500
|
-
flag_descs.append(fd)
|
|
501
|
-
correct_desc = f"Runs {base_cmd}"
|
|
670
|
+
flag_descs.append(f"{flag} ({fd.lower()})")
|
|
502
671
|
if flag_descs:
|
|
503
|
-
correct_desc += "
|
|
672
|
+
correct_desc += " using " + ", ".join(flag_descs)
|
|
504
673
|
|
|
505
674
|
# Generate distractors
|
|
506
675
|
distractor_descriptions = _generate_distractor_descriptions(correct_desc, 3)
|
|
@@ -709,7 +878,14 @@ def generate_build_command_quiz(
|
|
|
709
878
|
|
|
710
879
|
question_id = _generate_id(f"build_{cmd_string}")
|
|
711
880
|
|
|
712
|
-
|
|
881
|
+
# Use educational bash description for task if no intent/description available
|
|
882
|
+
if intent:
|
|
883
|
+
task_description = intent
|
|
884
|
+
elif description:
|
|
885
|
+
task_description = description
|
|
886
|
+
else:
|
|
887
|
+
# Generate educational description from the command
|
|
888
|
+
task_description = _generate_bash_description(cmd_string)
|
|
713
889
|
|
|
714
890
|
return QuizQuestion(
|
|
715
891
|
id=question_id,
|