learn_bash_from_session_data 1.0.6 → 1.0.7

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
@@ -240,26 +240,29 @@ function checkPython() {
240
240
  */
241
241
  function openInBrowser(filePath) {
242
242
  const platform = os.platform();
243
+ const isWindows = platform === 'win32' ||
244
+ process.env.MSYSTEM != null ||
245
+ (process.env.OSTYPE && process.env.OSTYPE.includes('msys')) ||
246
+ isWSL();
243
247
  let cmd;
244
248
  let args;
245
249
 
246
- switch (platform) {
247
- case 'darwin':
248
- cmd = 'open';
249
- args = [filePath];
250
- break;
251
- case 'win32':
252
- cmd = 'cmd';
253
- args = ['/c', 'start', '', filePath];
254
- break;
255
- default:
256
- // Linux and others
257
- cmd = 'xdg-open';
258
- args = [filePath];
250
+ if (platform === 'darwin') {
251
+ cmd = 'open';
252
+ args = [filePath];
253
+ } else if (isWindows) {
254
+ // Works from native Windows, MSYS, Git Bash, and WSL
255
+ cmd = process.env.COMSPEC || 'cmd.exe';
256
+ args = ['/c', 'start', '', filePath.replace(/\//g, '\\')];
257
+ } else {
258
+ cmd = 'xdg-open';
259
+ args = [filePath];
259
260
  }
260
261
 
261
262
  try {
262
- spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
263
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
264
+ child.on('error', () => {}); // Suppress async spawn errors
265
+ child.unref();
263
266
  return true;
264
267
  } catch (e) {
265
268
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn_bash_from_session_data",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Learn bash from your Claude Code sessions - extracts commands and generates interactive HTML lessons with 400+ commands, quizzes, and comprehensive coverage",
5
5
  "main": "bin/learn-bash.js",
6
6
  "bin": {
@@ -12,6 +12,16 @@ from pathlib import Path
12
12
  import html
13
13
  import json
14
14
 
15
+ try:
16
+ from scripts.knowledge_base import COMMAND_DB, get_flags_for_command, get_command_info
17
+ except ImportError:
18
+ try:
19
+ from knowledge_base import COMMAND_DB, get_flags_for_command, get_command_info
20
+ except ImportError:
21
+ COMMAND_DB = {}
22
+ def get_flags_for_command(cmd): return {}
23
+ def get_command_info(cmd): return None
24
+
15
25
 
16
26
  def _generate_html_impl(analysis_result: dict[str, Any], quizzes: list[dict[str, Any]]) -> str:
17
27
  """
@@ -376,7 +386,7 @@ def render_commands_tab(commands: list[dict]) -> str:
376
386
  # Syntax highlighted command
377
387
  highlighted = _syntax_highlight(cmd.get("full_command", ""))
378
388
 
379
- # Flags breakdown
389
+ # Flags breakdown with descriptions
380
390
  flags = cmd.get("flags", [])
381
391
  flags_html = ""
382
392
  if flags:
@@ -384,9 +394,27 @@ def render_commands_tab(commands: list[dict]) -> str:
384
394
  for flag in flags:
385
395
  flag_name = html.escape(flag.get("flag", ""))
386
396
  flag_desc = html.escape(flag.get("description", ""))
387
- flags_html += f'<li><code class="flag">{flag_name}</code> - {flag_desc}</li>'
397
+ if flag_desc:
398
+ flags_html += f'<li><code class="flag">{flag_name}</code> <span class="flag-desc">{flag_desc}</span></li>'
399
+ else:
400
+ flags_html += f'<li><code class="flag">{flag_name}</code></li>'
388
401
  flags_html += '</ul></div>'
389
402
 
403
+ # Subcommand description
404
+ subcommand_desc = cmd.get("subcommand_desc", "")
405
+ subcmd_html = ""
406
+ if subcommand_desc:
407
+ subcmd_html = f'<div class="subcmd-section"><span class="subcmd-label">Subcommand:</span> {html.escape(subcommand_desc)}</div>'
408
+
409
+ # Common patterns / examples from knowledge base
410
+ common_patterns = cmd.get("common_patterns", [])
411
+ patterns_html = ""
412
+ if common_patterns:
413
+ patterns_html = '<div class="patterns-section"><h5>Common Patterns:</h5><ul class="patterns-list">'
414
+ for pattern in common_patterns[:5]:
415
+ patterns_html += f'<li><code>{html.escape(pattern)}</code></li>'
416
+ patterns_html += '</ul></div>'
417
+
390
418
  # Output preview
391
419
  output_preview = cmd.get("output_preview", "")
392
420
  output_html = ""
@@ -418,7 +446,9 @@ def render_commands_tab(commands: list[dict]) -> str:
418
446
  <h5>Description:</h5>
419
447
  <p>{description}</p>
420
448
  </div>
449
+ {subcmd_html}
421
450
  {flags_html}
451
+ {patterns_html}
422
452
  {output_html}
423
453
  </div>
424
454
  </div>'''
@@ -512,13 +542,37 @@ def render_lessons_tab(categories: dict, commands: list[dict]) -> str:
512
542
  complexity = cmd.get("complexity", "simple")
513
543
  highlighted = _syntax_highlight(cmd.get("full_command", ""))
514
544
 
545
+ # Get flags and patterns for this command from COMMAND_DB
546
+ cmd_flags = cmd.get("flags", [])
547
+ lesson_flags_html = ""
548
+ if cmd_flags:
549
+ lesson_flags_html = '<div class="lesson-flags"><strong>Flags used:</strong> '
550
+ flag_parts = []
551
+ for flag in cmd_flags:
552
+ fname = html.escape(flag.get("flag", "") if isinstance(flag, dict) else str(flag))
553
+ fdesc = html.escape(flag.get("description", "") if isinstance(flag, dict) else "")
554
+ if fdesc:
555
+ flag_parts.append(f'<code class="flag">{fname}</code> ({fdesc})')
556
+ else:
557
+ flag_parts.append(f'<code class="flag">{fname}</code>')
558
+ lesson_flags_html += ', '.join(flag_parts) + '</div>'
559
+
560
+ # Subcommand info
561
+ subcmd_desc = cmd.get("subcommand_desc", "")
562
+ lesson_subcmd = ""
563
+ if subcmd_desc:
564
+ lesson_subcmd = f'<div class="lesson-subcmd"><em>{html.escape(subcmd_desc)}</em></div>'
565
+
515
566
  cat_commands_html += f'''
516
567
  <div class="lesson-command">
517
568
  <div class="lesson-command-header">
518
569
  <code class="cmd">{base_cmd}</code>
570
+ <span class="lesson-complexity complexity-{complexity}">{complexity}</span>
519
571
  </div>
520
572
  <pre class="syntax-highlighted">{highlighted}</pre>
521
573
  <p class="lesson-description">{description}</p>
574
+ {lesson_subcmd}
575
+ {lesson_flags_html}
522
576
  </div>'''
523
577
 
524
578
  # Patterns observed
@@ -1384,6 +1438,22 @@ def get_inline_css() -> str:
1384
1438
  color: #34a853;
1385
1439
  }
1386
1440
 
1441
+ .flag-desc { color: #6c757d; margin-left: 4px; }
1442
+ .flags-list li { margin: 4px 0; line-height: 1.5; }
1443
+ .subcmd-section { background: #f0f7ff; padding: 8px 12px; border-radius: 6px; margin: 8px 0; border-left: 3px solid #4a9eff; }
1444
+ .subcmd-label { font-weight: 600; color: #4a9eff; }
1445
+ .patterns-section { margin: 8px 0; }
1446
+ .patterns-section h5 { margin: 4px 0; color: #666; font-size: 0.85em; }
1447
+ .patterns-list { list-style: none; padding: 0; margin: 4px 0; }
1448
+ .patterns-list li { padding: 3px 0; }
1449
+ .patterns-list code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }
1450
+ .lesson-flags { margin: 6px 0; font-size: 0.9em; color: #555; }
1451
+ .lesson-subcmd { font-size: 0.9em; color: #4a9eff; margin: 4px 0; }
1452
+ .lesson-complexity { font-size: 0.75em; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
1453
+ .complexity-simple { background: #e8f5e9; color: #2e7d32; }
1454
+ .complexity-intermediate { background: #fff3e0; color: #e65100; }
1455
+ .complexity-advanced { background: #fce4ec; color: #c62828; }
1456
+
1387
1457
  .syntax-highlighted .string {
1388
1458
  color: #ff6d01;
1389
1459
  }
@@ -2107,27 +2177,70 @@ def generate_html_files(
2107
2177
  # Transform commands to expected format
2108
2178
  formatted_commands = []
2109
2179
  for cmd in analyzed_commands:
2110
- # Convert flags to expected format (list of dicts with 'flag' and 'description')
2180
+ cmd_str = cmd.get('command', '')
2181
+ base_cmd = cmd.get('base_command', cmd_str.split()[0] if cmd_str else '')
2182
+ complexity_score = cmd.get('complexity', 1)
2183
+
2184
+ # Look up COMMAND_DB info for this command
2185
+ cmd_info = COMMAND_DB.get(base_cmd, {})
2186
+ kb_flags = get_flags_for_command(base_cmd)
2187
+
2188
+ # Convert flags to expected format WITH descriptions from knowledge base
2111
2189
  raw_flags = cmd.get('flags', [])
2112
2190
  formatted_flags = []
2113
2191
  for f in raw_flags:
2114
- if isinstance(f, dict):
2115
- formatted_flags.append(f)
2192
+ if isinstance(f, dict) and 'flag' in f:
2193
+ # Already formatted - but enrich description if empty
2194
+ flag_name = f.get('flag', '')
2195
+ flag_desc = f.get('description', '')
2196
+ if not flag_desc and flag_name in kb_flags:
2197
+ flag_desc = kb_flags[flag_name]
2198
+ formatted_flags.append({'flag': flag_name, 'description': flag_desc})
2116
2199
  elif isinstance(f, str):
2117
- formatted_flags.append({'flag': f, 'description': ''})
2118
-
2119
- cmd_str = cmd.get('command', '')
2120
- complexity_score = cmd.get('complexity', 1)
2200
+ # Raw flag string - look up description from knowledge base
2201
+ flag_desc = kb_flags.get(f, '')
2202
+ # For combined flags like -la, try individual characters
2203
+ if not flag_desc and len(f) > 2 and f.startswith('-') and not f.startswith('--'):
2204
+ char_descs = []
2205
+ for char in f[1:]:
2206
+ single = f'-{char}'
2207
+ if single in kb_flags:
2208
+ char_descs.append(f'{single}: {kb_flags[single]}')
2209
+ if char_descs:
2210
+ flag_desc = '; '.join(char_descs)
2211
+ formatted_flags.append({'flag': f, 'description': flag_desc})
2212
+
2213
+ # Get educational description from COMMAND_DB if session description is empty/generic
2214
+ session_desc = cmd.get('description', '')
2215
+ kb_desc = cmd_info.get('description', '')
2216
+ description = session_desc if session_desc else kb_desc
2217
+
2218
+ # Get subcommand info (for commands like git, docker, npm)
2219
+ subcommands = cmd_info.get('subcommands', {})
2220
+ # Try to identify the subcommand from the full command
2221
+ cmd_tokens = cmd_str.split() if cmd_str else []
2222
+ subcommand_desc = ''
2223
+ if subcommands and len(cmd_tokens) > 1:
2224
+ for token in cmd_tokens[1:]:
2225
+ if not token.startswith('-') and token in subcommands:
2226
+ subcommand_desc = subcommands[token]
2227
+ break
2228
+
2229
+ # Get common patterns from COMMAND_DB
2230
+ common_patterns = cmd_info.get('common_patterns', [])
2121
2231
 
2122
2232
  formatted_commands.append({
2123
- 'base_command': cmd.get('base_command', cmd_str.split()[0] if cmd_str else ''),
2233
+ 'base_command': base_cmd,
2124
2234
  'full_command': cmd_str,
2125
2235
  'category': cmd.get('category', 'Other'),
2126
2236
  'complexity': complexity_to_label(complexity_score),
2127
2237
  'complexity_score': complexity_score,
2128
2238
  'frequency': frequency_map.get(cmd_str, 1),
2129
- 'description': cmd.get('description', ''),
2239
+ 'description': description,
2130
2240
  'flags': formatted_flags,
2241
+ 'subcommand_desc': subcommand_desc,
2242
+ 'common_patterns': common_patterns[:6],
2243
+ 'args': cmd.get('args', []),
2131
2244
  'is_new': False,
2132
2245
  })
2133
2246
 
@@ -729,20 +729,44 @@ def _generate_distractor_flags(cmd: str, correct_flag: str, count: int = 3) -> l
729
729
 
730
730
 
731
731
  def _generate_distractor_descriptions(correct_desc: str, count: int = 3) -> list[str]:
732
- """Generate plausible wrong descriptions."""
732
+ """Generate plausible wrong descriptions using command-level descriptions for length parity."""
733
733
  distractors = []
734
734
 
735
- # Collect all descriptions from merged sources
736
- all_descriptions = []
737
- for cmd in _get_all_flagged_commands():
738
- all_descriptions.extend(_get_flags_for_cmd(cmd).values())
739
-
740
- # Remove duplicates and the correct answer
741
- all_descriptions = list(set(all_descriptions))
742
- all_descriptions = [d for d in all_descriptions if d.lower() != correct_desc.lower()]
743
-
744
- random.shuffle(all_descriptions)
745
- return all_descriptions[:count]
735
+ # First: collect command-level descriptions from COMMAND_DB (similar length to correct answer)
736
+ cmd_descriptions = []
737
+ for cmd_name in COMMAND_DB:
738
+ cmd_info = COMMAND_DB[cmd_name]
739
+ desc = cmd_info.get('description', '')
740
+ if desc and desc.lower() != correct_desc.lower():
741
+ # Truncate very long descriptions to similar length as correct answer
742
+ max_len = max(len(correct_desc) + 40, 80)
743
+ if len(desc) > max_len:
744
+ desc = desc[:max_len].rsplit(' ', 1)[0] + '...'
745
+ cmd_descriptions.append(desc)
746
+
747
+ if cmd_descriptions:
748
+ random.shuffle(cmd_descriptions)
749
+ distractors.extend(cmd_descriptions[:count])
750
+
751
+ # Fallback: use flag descriptions if not enough command descriptions
752
+ if len(distractors) < count:
753
+ all_flag_descs = []
754
+ for cmd in _get_all_flagged_commands():
755
+ all_flag_descs.extend(_get_flags_for_cmd(cmd).values())
756
+ all_flag_descs = list(set(all_flag_descs))
757
+ all_flag_descs = [d for d in all_flag_descs if d.lower() != correct_desc.lower()]
758
+ random.shuffle(all_flag_descs)
759
+ distractors.extend(all_flag_descs[:count - len(distractors)])
760
+
761
+ # Remove duplicates
762
+ seen = set()
763
+ unique = []
764
+ for d in distractors:
765
+ dl = d.lower()
766
+ if dl not in seen:
767
+ seen.add(dl)
768
+ unique.append(d)
769
+ return unique[:count]
746
770
 
747
771
 
748
772
  def generate_what_does_quiz(
@@ -793,9 +817,27 @@ def generate_what_does_quiz(
793
817
  for rel_cmd in related_cmds[:3 - len(distractor_descriptions)]:
794
818
  distractor_descriptions.append(f"Runs {rel_cmd} to process files")
795
819
 
796
- # Ensure we have exactly 3 distractors
820
+ # Ensure we have exactly 3 distractors with plausible alternatives
821
+ fallback_actions = [
822
+ f"List directory contents with detailed file information",
823
+ f"Search recursively through files for matching patterns",
824
+ f"Display or modify file permissions and ownership",
825
+ f"Compress or archive files for storage and transfer",
826
+ f"Monitor system processes and resource usage",
827
+ f"Download files from a remote server or URL",
828
+ f"Edit configuration files in the default text editor",
829
+ f"Install or update packages from the package manager",
830
+ ]
831
+ random.shuffle(fallback_actions)
832
+ fb_idx = 0
797
833
  while len(distractor_descriptions) < 3:
798
- distractor_descriptions.append(f"Performs an unrelated {base_cmd} operation")
834
+ if fb_idx < len(fallback_actions):
835
+ fallback = fallback_actions[fb_idx]
836
+ if fallback.lower() != correct_desc.lower():
837
+ distractor_descriptions.append(fallback)
838
+ fb_idx += 1
839
+ else:
840
+ distractor_descriptions.append(f"Run a system utility to process input data")
799
841
 
800
842
  # Create options (shuffle positions)
801
843
  options = []
@@ -929,19 +971,20 @@ def generate_build_command_quiz(
929
971
  parsed = _parse_command(cmd_string)
930
972
  base_cmd = parsed["base"]
931
973
 
932
- # Create the correct command structure
933
- correct_components = [base_cmd] + parsed["flags"] + parsed["args"]
934
- correct_answer = " ".join(correct_components)
974
+ # Use the original command string as correct answer (preserves flag-argument ordering)
975
+ correct_answer = cmd_string.strip()
935
976
 
936
- # Generate wrong arrangements
977
+ # Generate wrong arrangements using parsed components
978
+ all_parts = [base_cmd] + parsed["flags"] + parsed["args"]
937
979
  distractors = []
938
980
 
939
- # Distractor 1: Wrong order
940
- if len(correct_components) > 2:
941
- wrong_order = correct_components.copy()
981
+ # Distractor 1: Wrong order of components
982
+ if len(all_parts) > 2:
983
+ wrong_order = all_parts.copy()
942
984
  random.shuffle(wrong_order)
943
- if wrong_order != correct_components:
944
- distractors.append(" ".join(wrong_order))
985
+ wrong_str = " ".join(wrong_order)
986
+ if wrong_str != correct_answer:
987
+ distractors.append(wrong_str)
945
988
 
946
989
  # Distractor 2: Missing flag
947
990
  if parsed["flags"]:
@@ -961,15 +1004,31 @@ def generate_build_command_quiz(
961
1004
  wrong_cmd = [related[0]] + parsed["flags"] + parsed["args"]
962
1005
  distractors.append(" ".join(wrong_cmd))
963
1006
 
964
- # Ensure we have exactly 3 distractors
1007
+ # Ensure we have exactly 3 distractors with plausible alternatives
1008
+ # Use real flags from the knowledge base as fallback distractors
1009
+ all_cmd_flags = list(_get_flags_for_cmd(base_cmd).keys())
1010
+ random.shuffle(all_cmd_flags)
1011
+ fb_flag_idx = 0
965
1012
  while len(distractors) < 3:
966
- # Add a clearly wrong option
967
- distractors.append(f"{base_cmd} --invalid-option")
1013
+ if fb_flag_idx < len(all_cmd_flags):
1014
+ fallback_flag = all_cmd_flags[fb_flag_idx]
1015
+ fallback_cmd = f"{base_cmd} {fallback_flag} {' '.join(parsed['args'])}"
1016
+ if fallback_cmd.strip() != correct_answer:
1017
+ distractors.append(fallback_cmd.strip())
1018
+ fb_flag_idx += 1
1019
+ else:
1020
+ # Use a related command as last resort
1021
+ related = _get_related_commands(base_cmd)
1022
+ if related:
1023
+ rel = related[len(distractors) % len(related)]
1024
+ distractors.append(f"{rel} {' '.join(parsed['flags'])} {' '.join(parsed['args'])}".strip())
1025
+ else:
1026
+ distractors.append(f"{base_cmd} {' '.join(parsed['args'])}".strip())
968
1027
 
969
1028
  # Remove duplicates and correct answer from distractors
970
1029
  distractors = list(set(d for d in distractors if d != correct_answer))[:3]
971
1030
  while len(distractors) < 3:
972
- distractors.append(f"{base_cmd} --wrong-flag")
1031
+ distractors.append(f"{base_cmd} {' '.join(parsed['args'])}".strip())
973
1032
 
974
1033
  # Create options
975
1034
  all_answers = [correct_answer] + distractors[:3]
@@ -1203,7 +1262,13 @@ def generate_quiz_set(
1203
1262
  target_build = max(1, int(count * 0.2))
1204
1263
  target_spot_diff = max(1, int(count * 0.15))
1205
1264
 
1206
- used_commands = set()
1265
+ # Track used commands per quiz type to avoid repeating the same command
1266
+ used_per_type = {
1267
+ QuizType.WHAT_DOES: set(),
1268
+ QuizType.WHICH_FLAG: set(),
1269
+ QuizType.BUILD_COMMAND: set(),
1270
+ QuizType.SPOT_DIFFERENCE: set(),
1271
+ }
1207
1272
 
1208
1273
  # Generate "What does this do?" questions
1209
1274
  random.shuffle(weighted_commands)
@@ -1211,38 +1276,47 @@ def generate_quiz_set(
1211
1276
  if len([q for q in questions if q.quiz_type == QuizType.WHAT_DOES]) >= target_what_does:
1212
1277
  break
1213
1278
  cmd_id = cmd.get("command", "")
1214
- if cmd_id not in used_commands:
1279
+ if cmd_id not in used_per_type[QuizType.WHAT_DOES]:
1215
1280
  q = generate_what_does_quiz(cmd)
1216
1281
  questions.append(q)
1217
- used_commands.add(cmd_id)
1282
+ used_per_type[QuizType.WHAT_DOES].add(cmd_id)
1218
1283
 
1219
1284
  # Generate "Which flag?" questions
1220
1285
  random.shuffle(weighted_commands)
1221
1286
  for cmd in weighted_commands:
1222
1287
  if len([q for q in questions if q.quiz_type == QuizType.WHICH_FLAG]) >= target_which_flag:
1223
1288
  break
1224
- q = generate_which_flag_quiz(cmd)
1225
- if q:
1226
- questions.append(q)
1289
+ cmd_id = cmd.get("command", "")
1290
+ if cmd_id not in used_per_type[QuizType.WHICH_FLAG]:
1291
+ q = generate_which_flag_quiz(cmd)
1292
+ if q:
1293
+ questions.append(q)
1294
+ used_per_type[QuizType.WHICH_FLAG].add(cmd_id)
1227
1295
 
1228
1296
  # Generate "Build the command" questions
1229
1297
  random.shuffle(weighted_commands)
1230
1298
  for cmd in weighted_commands:
1231
1299
  if len([q for q in questions if q.quiz_type == QuizType.BUILD_COMMAND]) >= target_build:
1232
1300
  break
1233
- q = generate_build_command_quiz(cmd)
1234
- questions.append(q)
1301
+ cmd_id = cmd.get("command", "")
1302
+ if cmd_id not in used_per_type[QuizType.BUILD_COMMAND]:
1303
+ q = generate_build_command_quiz(cmd)
1304
+ questions.append(q)
1305
+ used_per_type[QuizType.BUILD_COMMAND].add(cmd_id)
1235
1306
 
1236
1307
  # Generate "Spot the difference" questions
1237
1308
  random.shuffle(weighted_commands)
1238
1309
  for cmd in weighted_commands:
1239
1310
  if len([q for q in questions if q.quiz_type == QuizType.SPOT_DIFFERENCE]) >= target_spot_diff:
1240
1311
  break
1241
- variant = _create_similar_command_variant(cmd)
1242
- if variant:
1243
- q = generate_spot_difference_quiz(cmd, variant)
1244
- if q:
1245
- questions.append(q)
1312
+ cmd_id = cmd.get("command", "")
1313
+ if cmd_id not in used_per_type[QuizType.SPOT_DIFFERENCE]:
1314
+ variant = _create_similar_command_variant(cmd)
1315
+ if variant:
1316
+ q = generate_spot_difference_quiz(cmd, variant)
1317
+ if q:
1318
+ questions.append(q)
1319
+ used_per_type[QuizType.SPOT_DIFFERENCE].add(cmd_id)
1246
1320
 
1247
1321
  # Shuffle final questions
1248
1322
  random.shuffle(questions)