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 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
- return path.join(homeDir, '.claude', 'projects');
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 = sessions.length;
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.1",
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": "./bin/learn-bash.js",
8
- "bash-learner": "./bin/learn-bash.js"
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": ["bash", "learning", "claude", "cli", "terminal", "shell"],
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
- sorted_commands = sorted(commands, key=lambda x: x.get("frequency", 0), reverse=True)[:10]
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
- for cmd in sorted_commands:
137
- freq = cmd.get("frequency", 0)
138
- bar_width = (freq / max_freq) * 100
139
- cmd_name = html.escape(cmd.get("base_command", "unknown"))
140
- top_commands_html += f'''
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', cmd.get('command', '').split()[0] if cmd.get('command') else ''),
2003
- 'full_command': cmd.get('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': cmd.get('complexity', 1),
2006
- 'frequency': cmd.get('frequency', 1),
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
- SESSIONS_BASE_PATH = Path.home() / ".claude" / "projects"
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 SESSIONS_BASE_PATH.exists():
135
+ if not base_path.exists():
92
136
  return sessions
93
137
 
94
- # Find all session files
95
- for project_dir in SESSIONS_BASE_PATH.iterdir():
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
- sessions_dir = project_dir / "sessions"
100
- if not sessions_dir.exists():
101
- continue
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 session_file in sessions_dir.glob("*.jsonl"):
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
- sessions = discover_sessions(project_filter=project_filter)
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"\nExpected location: {SESSIONS_BASE_PATH}/<project-hash>/sessions/*.jsonl")
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"\nExpected location: {SESSIONS_BASE_PATH}/<project-hash>/sessions/*.jsonl")
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
- # Generate from flags
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 += " with: " + ", ".join(flag_descs)
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
- task_description = intent if intent else f"perform the operation: {description}"
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,