learn_bash_from_session_data 1.0.2 → 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/package.json CHANGED
@@ -1,16 +1,23 @@
1
1
  {
2
2
  "name": "learn_bash_from_session_data",
3
- "version": "1.0.2",
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()},
@@ -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,