ruby-shell 3.0.0 → 3.2.0

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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/PLUGIN_GUIDE.md +757 -0
  3. data/README.md +89 -6
  4. data/bin/rsh +858 -48
  5. metadata +5 -4
data/bin/rsh CHANGED
@@ -8,7 +8,7 @@
8
8
  # Web_site: http://isene.com/
9
9
  # Github: https://github.com/isene/rsh
10
10
  # License: Public domain
11
- @version = "3.0.0" # Major release: Persistent defuns, switch caching, smart suggestions, analytics, tooltips, bookmarks, validation, sessions
11
+ @version = "3.2.0" # Plugin system: Extensible architecture with lifecycle hooks, completions, commands, and plugin management
12
12
 
13
13
  # MODULES, CLASSES AND EXTENSIONS
14
14
  class String # Add coloring to strings (with escaping for Readline)
@@ -93,6 +93,7 @@ begin # Initialization
93
93
  @c_path = 3 # Color for valid path
94
94
  @c_switch = 6 # Color for switches/options
95
95
  @c_bookmark = 13 # Color for bookmarks
96
+ @c_colon = 4 # Color for colon commands
96
97
  @c_tabselect = 5 # Color for selected tabcompleted item
97
98
  @c_taboption = 244 # Color for unselected tabcompleted item
98
99
  @c_stamp = 244 # Color for time stamp/command
@@ -138,7 +139,21 @@ begin # Initialization
138
139
  @bookmarks = {} # Enhanced bookmarks with tags
139
140
  @defuns = {} # Store defun definitions for persistence
140
141
  @cmd_stats = {} # Command execution statistics
141
- @session_file = Dir.home + '/.rsh_session' # Session save file
142
+ @session_dir = Dir.home + '/.rsh/sessions' # Sessions directory
143
+ @session_file = @session_dir + '/default.json' # Default session file
144
+ @session_autosave = 0 # Auto-save interval (0 = disabled)
145
+ @session_last_save = Time.now.to_i # Last auto-save timestamp
146
+ @history_dedup = 'smart' # History dedup mode: 'off', 'full', 'smart'
147
+ @auto_correct = false # Auto-correct typos (default: off)
148
+ @slow_command_threshold = 0 # Threshold for slow command alerts (0 = disabled)
149
+ @plugin_dir = Dir.home + '/.rsh/plugins' # Plugins directory
150
+ @plugins = [] # Loaded plugin instances
151
+ @plugin_disabled = [] # List of disabled plugin names
152
+ @plugin_commands = {} # Commands added by plugins
153
+ # Built-in rsh commands are called with : prefix, so no need for separate tracking
154
+ Dir.mkdir(Dir.home + '/.rsh') unless Dir.exist?(Dir.home + '/.rsh')
155
+ Dir.mkdir(@session_dir) unless Dir.exist?(@session_dir)
156
+ Dir.mkdir(@plugin_dir) unless Dir.exist?(@plugin_dir)
142
157
  def pre_cmd; end # User-defined function to be run BEFORE command execution
143
158
  def post_cmd; end # User-defined function to be run AFTER command execution
144
159
  end
@@ -146,7 +161,7 @@ end
146
161
  # HELP TEXT
147
162
  @info = <<~INFO
148
163
 
149
- Hello #{@user}, welcome to rsh v3.0 - the Ruby SHell.
164
+ Hello #{@user}, welcome to rsh v3.2 - the Ruby SHell.
150
165
 
151
166
  rsh does not attempt to compete with the grand old shells like bash and zsh.
152
167
  It serves the specific needs and wants of its author. If you like it, then feel free
@@ -172,6 +187,28 @@ end
172
187
  * Syntax validation - Pre-execution warnings for dangerous or malformed commands
173
188
  * Unified command syntax - :nick, :gnick, :bm all work the same way (list/create/delete)
174
189
 
190
+ NEW in v3.2:
191
+ * Plugin system - Extensible architecture for custom commands, completions, and hooks
192
+ * Lifecycle hooks - on_startup, on_command_before, on_command_after, on_prompt
193
+ * Plugin management - :plugins list/reload/enable/disable/info
194
+ * Example plugins - git_prompt, command_logger, kubectl_completion included
195
+ * Auto-correct typos - :config "auto_correct" "on" automatically fixes gti → closest match
196
+ * Command timing alerts - :config "slow_command_threshold" "5" warns on slow commands
197
+ * Inline calculator - :calc 2 + 2, :calc "Math.sqrt(16)", full Ruby Math library
198
+ * Enhanced history - !!, !-2, !5:7 for repeat last, nth-to-last, and chaining
199
+ * Stats visualization - :stats --graph for colorful ASCII bar charts
200
+ * See PLUGIN_GUIDE.md for complete plugin development documentation
201
+
202
+ v3.1 Features:
203
+ * Multiple named sessions - :save_session "project" and :load_session "project"
204
+ * Stats export - :stats --csv or :stats --json for data analysis
205
+ * Session auto-save - Set @session_autosave = 300 in .rshrc for 5-min auto-save
206
+ * Bookmark import/export - :bm --export file.json and :bm --import file.json
207
+ * Bookmark statistics - :bm --stats shows usage patterns and tag distribution
208
+ * Color themes - :theme solarized|dracula|gruvbox|nord|monokai
209
+ * Config management - :config shows/sets history_dedup, session_autosave, etc.
210
+ * Environment management - :env for listing/setting/exporting environment variables
211
+
175
212
  Config file (.rshrc) updates on exit (Ctrl-d) or not (Ctrl-e).
176
213
  All colors are themeable in .rshrc (see github link for possibilities).
177
214
 
@@ -206,6 +243,7 @@ def firstrun
206
243
  @c_path = 208 # Color for valid path
207
244
  @c_switch = 148 # Color for switches/options
208
245
  @c_bookmark = 13 # Color for bookmarks
246
+ @c_colon = 4 # Color for colon commands
209
247
  @c_tabselect = 207 # Color for selected tabcompleted item
210
248
  @c_taboption = 244 # Color for unselected tabcompleted item
211
249
  @c_stamp = 244 # Color for time stamp/command
@@ -485,6 +523,7 @@ def tab(type)
485
523
  type = "switch" if @tabstr && @tabstr[0] == "-" && !@tabstr.include?("=")
486
524
  type = "option_value" if @tabstr && @tabstr =~ /^--?[\w-]+=/
487
525
  type = "env_vars" if @tabstr && @tabstr[0] == "$"
526
+ type = "colon_commands" if @tabstr && @tabstr[0] == ":"
488
527
 
489
528
  # Debug output when RSH_DEBUG is set
490
529
  if ENV['RSH_DEBUG']
@@ -576,12 +615,25 @@ def tab(type)
576
615
  env_vars = ENV.keys.map { |k| "$#{k}" }
577
616
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
578
617
  @tabarray = env_vars.select { |var| var =~ Regexp.new(@tabstr, regex_flags) }
618
+ when "colon_commands" # Ruby/rsh commands starting with :
619
+ colon_cmds = %w[
620
+ :nick :gnick :bm :bookmark :stats :defun :defun?
621
+ :history :rmhistory :jobs :fg :bg
622
+ :save_session :load_session :list_sessions :delete_session :rmsession
623
+ :config :env :theme :plugins :calc
624
+ :info :version :help
625
+ ]
626
+ search_str = @tabstr[1..-1] || "" # Remove leading :
627
+ regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
628
+ matches = colon_cmds.select { |cmd| cmd[1..-1] =~ Regexp.new("^#{search_str}", regex_flags) }
629
+ @tabarray = matches
579
630
  when "all" # Handle all other tab completions
580
631
  ex = []
581
632
  ex += @exe
582
633
  ex.sort!
583
634
  ex.prepend(*@nick.keys) # Add nicks
584
635
  ex.prepend(*@gnick.keys) # Add gnicks
636
+ ex.prepend(*@bookmarks.keys) if @bookmarks # Add bookmarks
585
637
 
586
638
  # Enhanced matching with case sensitivity and fuzzy support
587
639
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
@@ -829,9 +881,106 @@ def suggest_command(cmd) # Smart command suggestions for typos
829
881
  candidates.first(3)
830
882
  end
831
883
  def hist_clean # Clean up @history
832
- @history.uniq!
833
884
  @history.compact!
834
885
  @history.delete("")
886
+
887
+ # Apply deduplication based on mode
888
+ case @history_dedup
889
+ when 'off'
890
+ # No deduplication
891
+ when 'full', 'smart'
892
+ # Remove duplicates, keeping first (most recent) occurrence
893
+ @history.uniq!
894
+ else
895
+ # Default to smart
896
+ @history.uniq!
897
+ end
898
+ end
899
+ def config(*args) # Configure rsh settings
900
+ setting = args[0]
901
+ value = args[1]
902
+
903
+ if setting.nil?
904
+ # Show current configuration
905
+ puts "\n Current Configuration:".c(@c_prompt).b
906
+ puts " history_dedup: #{@history_dedup}"
907
+ puts " session_autosave: #{@session_autosave}s #{@session_autosave > 0 ? '(enabled)' : '(disabled)'}"
908
+ puts " auto_correct: #{@auto_correct ? 'on' : 'off'}"
909
+ puts " slow_command_threshold: #{@slow_command_threshold}s #{@slow_command_threshold > 0 ? '(enabled)' : '(disabled)'}"
910
+ puts " completion_limit: #{@completion_limit}"
911
+ puts " completion_fuzzy: #{@completion_fuzzy}"
912
+ puts " completion_case_sensitive: #{@completion_case_sensitive}"
913
+ puts
914
+ return
915
+ end
916
+
917
+ case setting
918
+ when 'history_dedup'
919
+ if %w[off full smart].include?(value)
920
+ @history_dedup = value
921
+ puts "History deduplication set to '#{value}'"
922
+ rshrc
923
+ else
924
+ puts "Invalid value. Use: off, full, or smart"
925
+ end
926
+ when 'session_autosave'
927
+ @session_autosave = value.to_i
928
+ puts "Session auto-save set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
929
+ rshrc
930
+ when 'auto_correct'
931
+ @auto_correct = %w[on true yes 1].include?(value.to_s.downcase)
932
+ puts "Auto-correct #{@auto_correct ? 'enabled' : 'disabled'}"
933
+ rshrc
934
+ when 'slow_command_threshold'
935
+ @slow_command_threshold = value.to_i
936
+ puts "Slow command threshold set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
937
+ rshrc
938
+ when 'completion_limit'
939
+ @completion_limit = value.to_i
940
+ puts "Completion limit set to #{value}"
941
+ rshrc
942
+ else
943
+ puts "Unknown setting '#{setting}'"
944
+ puts "Available: history_dedup, session_autosave, auto_correct, slow_command_threshold, completion_limit"
945
+ end
946
+ end
947
+ def env(*args) # Environment variable management
948
+ arg_str = args.join(' ')
949
+
950
+ if args.empty?
951
+ # List all environment variables
952
+ puts "\n Environment Variables:".c(@c_prompt).b
953
+ ENV.sort.first(20).each do |key, value|
954
+ value_display = value.length > 50 ? value[0..47] + '...' : value
955
+ puts " #{key.c(@c_gnick).ljust(25)} = #{value_display}"
956
+ end
957
+ puts " ... (#{ENV.length} total, showing first 20)"
958
+ puts "\n Use :env \"VARNAME\" to see specific variable"
959
+ puts
960
+ elsif arg_str =~ /^set\s+(\w+)\s+(.+)$/
961
+ # Set environment variable
962
+ var_name, var_value = $1, $2
963
+ ENV[var_name] = var_value
964
+ puts "#{var_name} = #{var_value}"
965
+ elsif arg_str =~ /^unset\s+(\w+)$/
966
+ # Unset environment variable
967
+ var_name = $1
968
+ ENV.delete(var_name)
969
+ puts "#{var_name} unset"
970
+ elsif arg_str =~ /^export\s+(.+)$/
971
+ # Export to shell script
972
+ filename = $1
973
+ File.write(filename, ENV.map { |k,v| "export #{k}=\"#{v}\"" }.join("\n"))
974
+ puts "Environment exported to #{filename}"
975
+ else
976
+ # Show specific variable
977
+ var_name = arg_str.strip
978
+ if ENV[var_name]
979
+ puts "#{var_name} = #{ENV[var_name]}"
980
+ else
981
+ puts "Environment variable '#{var_name}' not set"
982
+ end
983
+ end
835
984
  end
836
985
  def cmd_check(str) # Check if each element on the readline matches commands, nicks, paths; color them
837
986
  return if str.nil?
@@ -840,7 +989,14 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
840
989
  if str =~ /^(@@?)\s+(.*)$/
841
990
  prefix = $1
842
991
  rest = $2
843
- return prefix.c(4) + " " + rest # Color @ or @@ in blue (4), rest uncolored
992
+ return prefix.c(@c_colon) + " " + rest # Color @ or @@ in colon color
993
+ end
994
+
995
+ # Special handling for : commands
996
+ if str =~ /^(:[\w?_]+)/
997
+ colon_cmd = $1
998
+ rest = str.sub(/^:[\w?_]+/, '')
999
+ return colon_cmd.c(@c_colon) + rest # Color colon commands
844
1000
  end
845
1001
 
846
1002
  str.gsub(/(?:\S'[^']*'|[^ '])+/) do |el|
@@ -892,6 +1048,16 @@ def rshrc # Write updates to .rshrc
892
1048
  conf += "@bookmarks = #{@bookmarks}\n" unless @bookmarks.empty?
893
1049
  conf.sub!(/^@defuns.*(\n|$)/, "")
894
1050
  conf += "@defuns = #{@defuns}\n" unless @defuns.empty?
1051
+ conf.sub!(/^@history_dedup.*(\n|$)/, "")
1052
+ conf += "@history_dedup = '#{@history_dedup}'\n" if @history_dedup && @history_dedup != 'smart'
1053
+ conf.sub!(/^@session_autosave.*(\n|$)/, "")
1054
+ conf += "@session_autosave = #{@session_autosave}\n" if @session_autosave && @session_autosave > 0
1055
+ conf.sub!(/^@auto_correct.*(\n|$)/, "")
1056
+ conf += "@auto_correct = #{@auto_correct}\n" if @auto_correct
1057
+ conf.sub!(/^@slow_command_threshold.*(\n|$)/, "")
1058
+ conf += "@slow_command_threshold = #{@slow_command_threshold}\n" if @slow_command_threshold && @slow_command_threshold > 0
1059
+ conf.sub!(/^@plugin_disabled.*(\n|$)/, "")
1060
+ conf += "@plugin_disabled = #{@plugin_disabled}\n" unless @plugin_disabled.empty?
895
1061
  # Only write @cmd_completions if user has customized it
896
1062
  unless conf =~ /^@cmd_completions\s*=/
897
1063
  # Don't write default completions to avoid cluttering .rshrc
@@ -928,6 +1094,7 @@ def help
928
1094
  left_col << "RIGHT/Ctrl-F Accept suggestion"
929
1095
  left_col << "UP/DOWN Navigate history"
930
1096
  left_col << "TAB Tab complete"
1097
+ left_col << "Shift-TAB Search history"
931
1098
  left_col << "Ctrl-Y Copy to clipboard"
932
1099
  left_col << "Ctrl-D Exit + save .rshrc"
933
1100
  left_col << "Ctrl-E Exit without save"
@@ -964,15 +1131,28 @@ def help
964
1131
  right_col << ":fg [id] Foreground job"
965
1132
  right_col << ":bg [id] Resume in bg"
966
1133
  right_col << ""
967
- right_col << "v3.0 NEW FEATURES:".c(@c_prompt).b
968
- right_col << ":stats Command analytics"
969
- right_col << ":bm \"name\" Create bookmark"
970
- right_col << "name Jump to bookmark"
971
- right_col << ":bm List bookmarks"
972
- right_col << ":bm \"-name\" Delete bookmark"
973
- right_col << ":bm \"?tag\" Search by tag"
974
- right_col << ":save_session Save session"
975
- right_col << ":load_session Restore session"
1134
+ right_col << "v3.2 NEW FEATURES:".c(@c_prompt).b
1135
+ right_col << ":plugins [cmd] Plugin system"
1136
+ right_col << ":stats --graph Visual bar charts"
1137
+ right_col << ":calc 2 + 2 Ruby calculator"
1138
+ right_col << "!!, !-2, !5:7 History repeat/chain"
1139
+ right_col << ":config auto_correct Auto-fix (w/confirm)"
1140
+ right_col << ":config slow_cmd... Slow alerts"
1141
+ right_col << ""
1142
+ right_col << "PLUGIN SYSTEM:".c(@c_prompt).b
1143
+ right_col << ":plugins List all"
1144
+ right_col << ":plugins reload Reload"
1145
+ right_col << ":plugins disable nm Disable"
1146
+ right_col << ":plugins info nm Details"
1147
+ right_col << ""
1148
+ right_col << "v3.0/3.1 FEATURES:".c(@c_prompt).b
1149
+ right_col << ":stats --clear Clear stats"
1150
+ right_col << ":stats --csv|--json Export stats"
1151
+ right_col << ":bm \"name\" Bookmarks"
1152
+ right_col << ":save_session [nm] Sessions"
1153
+ right_col << ":theme [name] Themes"
1154
+ right_col << ":config [set val] Config"
1155
+ right_col << ":env [VAR] Env vars"
976
1156
  right_col << ""
977
1157
  right_col << "INTEGRATIONS:".c(@c_prompt).b
978
1158
  right_col << "r Launch rtfm"
@@ -1176,7 +1356,36 @@ def defun? # Show all user-defined functions
1176
1356
  end
1177
1357
  end
1178
1358
  end
1179
- def stats # Show command execution statistics and analytics
1359
+ def stats(*args) # Show command execution statistics and analytics
1360
+ format = args[0]
1361
+ filename = args[1]
1362
+
1363
+ if format == "--export"
1364
+ # Export to file
1365
+ fname = filename || "rsh_stats.json"
1366
+ export_stats(fname)
1367
+ return
1368
+ elsif format == "--json"
1369
+ fname = filename || "rsh_stats.json"
1370
+ export_stats_json(fname)
1371
+ return
1372
+ elsif format == "--csv"
1373
+ fname = filename || "rsh_stats.csv"
1374
+ export_stats_csv(fname)
1375
+ return
1376
+ elsif format == "--graph"
1377
+ stats_graph
1378
+ return
1379
+ elsif format == "--clear"
1380
+ # Clear all statistics
1381
+ @cmd_frequency = {}
1382
+ @cmd_stats = {}
1383
+ puts "All statistics cleared"
1384
+ rshrc
1385
+ return
1386
+ end
1387
+
1388
+ # Display stats (existing code)
1180
1389
  puts "\n Command Execution Statistics".c(@c_prompt).b
1181
1390
  puts " " + "="*50
1182
1391
 
@@ -1216,11 +1425,115 @@ def stats # Show command execution statistics and analytics
1216
1425
  puts "\n Last command exit status: #{@last_exit == 0 ? 'Success'.c(@c_path) : "Failed (#{@last_exit})".c(196)}"
1217
1426
  puts
1218
1427
  end
1219
- def bm(args = nil) # Enhanced bookmark management with tags
1220
- if args.nil? || args.empty?
1428
+ def stats_graph # Visual graph mode for stats
1429
+ puts "\n Command Usage Graph".c(@c_prompt).b
1430
+ puts " " + "="*50
1431
+
1432
+ return puts "No data to display" if @cmd_frequency.nil? || @cmd_frequency.empty?
1433
+
1434
+ sorted = @cmd_frequency.sort_by { |_, count| -count }.first(15)
1435
+ max_count = sorted.first[1]
1436
+ max_width = 40
1437
+
1438
+ puts
1439
+ sorted.each_with_index do |(cmd, count), i|
1440
+ # Calculate bar width (scaled to max_width)
1441
+ bar_width = (count.to_f / max_count * max_width).round
1442
+ bar = "█" * bar_width
1443
+
1444
+ # Color bars by intensity
1445
+ color = case bar_width
1446
+ when 0..10 then 244 # Gray
1447
+ when 11..20 then 3 # Yellow
1448
+ when 21..30 then 214 # Orange
1449
+ else 196 # Red
1450
+ end
1451
+
1452
+ puts " #{cmd.ljust(15)} #{count.to_s.rjust(4)}x #{bar.c(color)}"
1453
+ end
1454
+
1455
+ # Performance graph if data exists
1456
+ if @cmd_stats && !@cmd_stats.empty?
1457
+ puts "\n Command Performance Graph (avg time)".c(@c_prompt).b
1458
+ puts " " + "="*50
1459
+ puts
1460
+
1461
+ slowest = @cmd_stats.sort_by { |_, s| -(s[:avg_time] || 0) }.first(10)
1462
+ max_time = slowest.first[1][:avg_time]
1463
+
1464
+ slowest.each do |cmd, stats|
1465
+ bar_width = (stats[:avg_time] / max_time * max_width).round
1466
+ bar = "█" * bar_width
1467
+
1468
+ color = case bar_width
1469
+ when 0..10 then 2 # Green (fast)
1470
+ when 11..20 then 214 # Orange
1471
+ else 196 # Red (slow)
1472
+ end
1473
+
1474
+ time_str = "#{'%.3f' % stats[:avg_time]}s"
1475
+ puts " #{cmd.ljust(15)} #{time_str.rjust(8)} #{bar.c(color)}"
1476
+ end
1477
+ end
1478
+
1479
+ puts
1480
+ end
1481
+ def export_stats(filename) # Export stats to file (JSON or CSV based on extension)
1482
+ if filename.end_with?('.csv')
1483
+ export_stats_csv(filename)
1484
+ else
1485
+ filename += '.json' unless filename.end_with?('.json')
1486
+ export_stats_json(filename)
1487
+ end
1488
+ end
1489
+ def export_stats_json(filename = 'rsh_stats.json') # Export stats to JSON
1490
+ stats_data = {
1491
+ generated: Time.now.to_i,
1492
+ cmd_frequency: @cmd_frequency,
1493
+ cmd_stats: @cmd_stats,
1494
+ history: {
1495
+ total: @history.length,
1496
+ unique: @history.uniq.length
1497
+ },
1498
+ last_exit: @last_exit
1499
+ }
1500
+ begin
1501
+ require 'json'
1502
+ File.write(filename, JSON.pretty_generate(stats_data))
1503
+ puts "Stats exported to #{filename}"
1504
+ rescue => e
1505
+ puts "Error exporting stats: #{e.message}"
1506
+ end
1507
+ end
1508
+ def export_stats_csv(filename = 'rsh_stats.csv') # Export stats to CSV
1509
+ begin
1510
+ lines = []
1511
+ lines << "command,frequency,count,total_time,avg_time"
1512
+
1513
+ # Merge frequency and performance data
1514
+ all_cmds = (@cmd_frequency.keys + @cmd_stats.keys).uniq
1515
+ all_cmds.sort.each do |cmd|
1516
+ freq = @cmd_frequency[cmd] || 0
1517
+ count = @cmd_stats.dig(cmd, :count) || 0
1518
+ total = @cmd_stats.dig(cmd, :total_time) || 0.0
1519
+ avg = @cmd_stats.dig(cmd, :avg_time) || 0.0
1520
+ lines << "#{cmd},#{freq},#{count},#{'%.3f' % total},#{'%.3f' % avg}"
1521
+ end
1522
+
1523
+ File.write(filename, lines.join("\n"))
1524
+ puts "Stats exported to #{filename}"
1525
+ rescue => e
1526
+ puts "Error exporting stats: #{e.message}"
1527
+ end
1528
+ end
1529
+ def bm(*args) # Enhanced bookmark management with tags
1530
+ # Handle variadic arguments
1531
+ arg_str = args.join(' ')
1532
+
1533
+ if args.empty?
1221
1534
  # List all bookmarks
1222
1535
  if @bookmarks.empty?
1223
- puts "No bookmarks defined. Use :bookmark <name> to bookmark current directory"
1536
+ puts "No bookmarks defined. Use :bm \"name\" to bookmark current directory"
1224
1537
  return
1225
1538
  end
1226
1539
  puts "\n Bookmarks:".c(@c_prompt).b
@@ -1230,7 +1543,18 @@ def bm(args = nil) # Enhanced bookmark management with tags
1230
1543
  puts " #{name.c(@c_nick)} → #{path}#{tags.c(@c_stamp)}"
1231
1544
  end
1232
1545
  puts
1233
- elsif args =~ /^(\w+)\s+(.+)$/
1546
+ elsif args[0] == '--export'
1547
+ # Export bookmarks to file
1548
+ filename = args[1] || 'bookmarks.json'
1549
+ export_bookmarks(filename)
1550
+ elsif args[0] == '--import'
1551
+ # Import bookmarks from file
1552
+ filename = args[1]
1553
+ import_bookmarks(filename) if filename
1554
+ elsif args[0] == '--stats'
1555
+ # Show bookmark statistics
1556
+ bookmark_stats
1557
+ elsif arg_str =~ /^(\w+)\s+(.+)$/
1234
1558
  # Set bookmark with optional tags
1235
1559
  name, rest = $1, $2
1236
1560
  if rest.include?('#')
@@ -1244,7 +1568,7 @@ def bm(args = nil) # Enhanced bookmark management with tags
1244
1568
  end
1245
1569
  puts "Bookmark '#{name}' set to #{@bookmarks[name][:path]}"
1246
1570
  rshrc
1247
- elsif args =~ /^-(\w+)$/
1571
+ elsif arg_str =~ /^-(\w+)$/
1248
1572
  # Delete bookmark
1249
1573
  name = $1
1250
1574
  if @bookmarks.delete(name)
@@ -1253,7 +1577,7 @@ def bm(args = nil) # Enhanced bookmark management with tags
1253
1577
  else
1254
1578
  puts "Bookmark '#{name}' not found"
1255
1579
  end
1256
- elsif args =~ /^\?(\w*)$/
1580
+ elsif arg_str =~ /^\?(\w*)$/
1257
1581
  # Search bookmarks by tag
1258
1582
  tag = $1
1259
1583
  if tag.empty?
@@ -1273,17 +1597,82 @@ def bm(args = nil) # Enhanced bookmark management with tags
1273
1597
  end
1274
1598
  else
1275
1599
  # Bookmark current directory
1276
- name = args.strip
1600
+ name = arg_str.strip
1277
1601
  @bookmarks[name] = {path: Dir.pwd, tags: []}
1278
1602
  puts "Bookmark '#{name}' set to #{Dir.pwd}"
1279
1603
  rshrc
1280
1604
  end
1281
1605
  end
1282
- def bookmark(args = nil) # Alias for bm
1283
- bm(args)
1606
+ def bookmark(*args) # Alias for bm
1607
+ bm(*args)
1608
+ end
1609
+ def export_bookmarks(filename = 'bookmarks.json') # Export bookmarks to JSON
1610
+ begin
1611
+ require 'json'
1612
+ File.write(filename, JSON.pretty_generate(@bookmarks))
1613
+ puts "Bookmarks exported to #{filename}"
1614
+ rescue => e
1615
+ puts "Error exporting bookmarks: #{e.message}"
1616
+ end
1617
+ end
1618
+ def import_bookmarks(filename) # Import bookmarks from JSON
1619
+ unless File.exist?(filename)
1620
+ puts "File '#{filename}' not found"
1621
+ return
1622
+ end
1623
+ begin
1624
+ require 'json'
1625
+ imported = JSON.parse(File.read(filename))
1626
+ imported.each do |name, data|
1627
+ # Convert to proper format
1628
+ if data.is_a?(Hash)
1629
+ @bookmarks[name] = {
1630
+ path: data['path'] || data[:path],
1631
+ tags: data['tags'] || data[:tags] || []
1632
+ }.transform_keys(&:to_sym)
1633
+ else
1634
+ @bookmarks[name] = {path: data.to_s, tags: []}
1635
+ end
1636
+ end
1637
+ puts "Imported #{imported.length} bookmarks from #{filename}"
1638
+ rshrc
1639
+ rescue => e
1640
+ puts "Error importing bookmarks: #{e.message}"
1641
+ end
1642
+ end
1643
+ def bookmark_stats # Show bookmark usage statistics
1644
+ if @bookmarks.empty?
1645
+ puts "No bookmarks defined"
1646
+ return
1647
+ end
1648
+
1649
+ puts "\n Bookmark Statistics".c(@c_prompt).b
1650
+ puts " " + "="*50
1651
+ puts "\n Total bookmarks: #{@bookmarks.length}"
1652
+
1653
+ # Count by tags
1654
+ all_tags = @bookmarks.values.flat_map { |d| d.is_a?(Hash) ? (d[:tags] || []) : [] }
1655
+ unless all_tags.empty?
1656
+ puts "\n Tags Distribution:".c(@c_nick)
1657
+ tag_counts = all_tags.group_by(&:itself).transform_values(&:count)
1658
+ tag_counts.sort_by { |_, count| -count }.each do |tag, count|
1659
+ puts " #{tag.ljust(15)} #{count}x"
1660
+ end
1661
+ end
1662
+
1663
+ # Bookmarks by directory depth
1664
+ puts "\n Path Analysis:".c(@c_nick)
1665
+ paths = @bookmarks.values.map { |d| d.is_a?(Hash) ? d[:path] : d }
1666
+ avg_depth = paths.map { |p| p.split('/').length }.sum / paths.length
1667
+ puts " Average path depth: #{avg_depth}"
1668
+ puts
1284
1669
  end
1285
- def save_session # Save current session state
1670
+ def save_session(*args) # Save current session state
1671
+ session_name = args[0] || 'default'
1672
+ session_path = @session_dir + "/#{session_name}.json"
1673
+
1286
1674
  session = {
1675
+ name: session_name,
1287
1676
  pwd: Dir.pwd,
1288
1677
  history: @history.first(50),
1289
1678
  bookmarks: @bookmarks,
@@ -1292,20 +1681,24 @@ def save_session # Save current session state
1292
1681
  }
1293
1682
  begin
1294
1683
  require 'json'
1295
- File.write(@session_file, JSON.pretty_generate(session))
1296
- puts "Session saved to #{@session_file}"
1684
+ File.write(session_path, JSON.pretty_generate(session))
1685
+ puts "Session '#{session_name}' saved to #{session_path}"
1297
1686
  rescue => e
1298
1687
  puts "Error saving session: #{e.message}"
1299
1688
  end
1300
1689
  end
1301
- def load_session # Restore previous session
1302
- unless File.exist?(@session_file)
1303
- puts "No saved session found"
1690
+ def load_session(*args) # Restore previous session
1691
+ session_name = args[0] || 'default'
1692
+ session_path = @session_dir + "/#{session_name}.json"
1693
+
1694
+ unless File.exist?(session_path)
1695
+ puts "Session '#{session_name}' not found"
1696
+ list_sessions
1304
1697
  return
1305
1698
  end
1306
1699
  begin
1307
1700
  require 'json'
1308
- session = JSON.parse(File.read(@session_file), symbolize_names: true)
1701
+ session = JSON.parse(File.read(session_path), symbolize_names: true)
1309
1702
 
1310
1703
  # Restore state
1311
1704
  Dir.chdir(session[:pwd]) if session[:pwd] && Dir.exist?(session[:pwd])
@@ -1338,12 +1731,273 @@ def load_session # Restore previous session
1338
1731
  end
1339
1732
 
1340
1733
  saved_time = Time.at(session[:timestamp] || 0).strftime("%Y-%m-%d %H:%M:%S")
1341
- puts "Session restored from #{saved_time}"
1734
+ puts "Session '#{session_name}' restored from #{saved_time}"
1342
1735
  rshrc
1343
1736
  rescue => e
1344
1737
  puts "Error loading session: #{e.message}"
1345
1738
  end
1346
1739
  end
1740
+ def list_sessions # List all saved sessions
1741
+ unless Dir.exist?(@session_dir)
1742
+ puts "No sessions directory found"
1743
+ return
1744
+ end
1745
+
1746
+ sessions = Dir.glob(@session_dir + '/*.json').map { |f| File.basename(f, '.json') }
1747
+
1748
+ if sessions.empty?
1749
+ puts "No saved sessions found. Use :save_session \"name\" to create one"
1750
+ return
1751
+ end
1752
+
1753
+ puts "\n Saved Sessions:".c(@c_prompt).b
1754
+ sessions.sort.each do |name|
1755
+ session_path = @session_dir + "/#{name}.json"
1756
+ begin
1757
+ require 'json'
1758
+ session = JSON.parse(File.read(session_path), symbolize_names: true)
1759
+ timestamp = Time.at(session[:timestamp] || 0).strftime("%Y-%m-%d %H:%M")
1760
+ pwd = session[:pwd] || '?'
1761
+ puts " #{name.c(@c_bookmark).ljust(20)} #{timestamp.c(@c_stamp)} #{pwd.c(@c_path)}"
1762
+ rescue => e
1763
+ puts " #{name.c(@c_bookmark).ljust(20)} [corrupted]".c(196)
1764
+ end
1765
+ end
1766
+ puts
1767
+ end
1768
+ def delete_session(*args) # Delete a saved session
1769
+ name = args[0]
1770
+
1771
+ if name == '*'
1772
+ # Delete all sessions except default
1773
+ sessions = Dir.glob(@session_dir + '/*.json').map { |f| File.basename(f, '.json') }
1774
+ sessions.reject! { |s| s == 'default' || s == 'autosave' }
1775
+
1776
+ if sessions.empty?
1777
+ puts "No sessions to delete (keeping default and autosave)"
1778
+ return
1779
+ end
1780
+
1781
+ sessions.each do |session_name|
1782
+ session_path = @session_dir + "/#{session_name}.json"
1783
+ File.delete(session_path)
1784
+ end
1785
+ puts "Deleted #{sessions.length} sessions: #{sessions.join(', ')}"
1786
+ return
1787
+ end
1788
+
1789
+ return puts "Cannot delete default session" if name == 'default'
1790
+ return puts "Cannot delete autosave session (use * to delete all)" if name == 'autosave'
1791
+
1792
+ session_path = @session_dir + "/#{name}.json"
1793
+ unless File.exist?(session_path)
1794
+ puts "Session '#{name}' not found"
1795
+ return
1796
+ end
1797
+
1798
+ File.delete(session_path)
1799
+ puts "Session '#{name}' deleted"
1800
+ end
1801
+ def rmsession(*args) # Alias for delete_session
1802
+ delete_session(*args)
1803
+ end
1804
+ def theme(*args) # Apply color scheme presets
1805
+ name = args[0]
1806
+
1807
+ if name.nil?
1808
+ puts "\n Available themes:".c(@c_prompt).b
1809
+ puts " default, solarized, dracula, gruvbox, nord, monokai"
1810
+ puts "\n Current theme colors:"
1811
+ puts " prompt:#{' '*5}#{@c_prompt} cmd:#{' '*8}#{@c_cmd} nick:#{' '*7}#{@c_nick}"
1812
+ puts " gnick:#{' '*6}#{@c_gnick} path:#{' '*7}#{@c_path} switch:#{' '*5}#{@c_switch}"
1813
+ puts " bookmark:#{' '*3}#{@c_bookmark} colon:#{' '*6}#{@c_colon} tabselect:#{' '*2}#{@c_tabselect}"
1814
+ puts " taboption:#{' '*2}#{@c_taboption} stamp:#{' '*6}#{@c_stamp}"
1815
+ puts
1816
+ return
1817
+ end
1818
+
1819
+ case name.downcase
1820
+ when 'default'
1821
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 10, 2, 6, 14
1822
+ @c_path, @c_switch, @c_bookmark, @c_colon = 3, 6, 13, 4
1823
+ @c_tabselect, @c_taboption, @c_stamp = 5, 244, 244
1824
+ when 'solarized'
1825
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 33, 64, 37, 117
1826
+ @c_path, @c_switch, @c_bookmark, @c_colon = 136, 125, 61, 33
1827
+ @c_tabselect, @c_taboption, @c_stamp = 166, 240, 240
1828
+ when 'dracula'
1829
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 141, 84, 117, 212
1830
+ @c_path, @c_switch, @c_bookmark, @c_colon = 228, 215, 141, 141
1831
+ @c_tabselect, @c_taboption, @c_stamp = 212, 238, 238
1832
+ when 'gruvbox'
1833
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 214, 142, 109, 175
1834
+ @c_path, @c_switch, @c_bookmark, @c_colon = 208, 142, 167, 214
1835
+ @c_tabselect, @c_taboption, @c_stamp = 208, 243, 243
1836
+ when 'nord'
1837
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 110, 109, 116, 152
1838
+ @c_path, @c_switch, @c_bookmark, @c_colon = 180, 109, 139, 110
1839
+ @c_tabselect, @c_taboption, @c_stamp = 143, 240, 240
1840
+ when 'monokai'
1841
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 197, 112, 81, 141
1842
+ @c_path, @c_switch, @c_bookmark, @c_colon = 228, 208, 141, 197
1843
+ @c_tabselect, @c_taboption, @c_stamp = 197, 238, 238
1844
+ else
1845
+ puts "Unknown theme '#{name}'. Available: default, solarized, dracula, gruvbox, nord, monokai"
1846
+ return
1847
+ end
1848
+
1849
+ puts "Theme '#{name}' applied"
1850
+ puts "Add this to .rshrc to make it permanent: :theme \"#{name}\""
1851
+ end
1852
+ def load_plugins # Load all plugins from plugin directory
1853
+ return unless Dir.exist?(@plugin_dir)
1854
+
1855
+ plugin_files = Dir.glob(@plugin_dir + '/*.rb').sort
1856
+
1857
+ plugin_files.each do |plugin_file|
1858
+ plugin_name = File.basename(plugin_file, '.rb')
1859
+
1860
+ # Skip if disabled
1861
+ next if @plugin_disabled.include?(plugin_name)
1862
+
1863
+ begin
1864
+ # Load the plugin file
1865
+ load(plugin_file)
1866
+
1867
+ # Find the plugin class (conventionally PluginNamePlugin)
1868
+ class_name = plugin_name.split('_').map(&:capitalize).join + 'Plugin'
1869
+
1870
+ # Try to instantiate the plugin
1871
+ if Object.const_defined?(class_name)
1872
+ plugin_class = Object.const_get(class_name)
1873
+ rsh_context = {
1874
+ version: @version,
1875
+ history: @history,
1876
+ bookmarks: @bookmarks,
1877
+ nick: @nick,
1878
+ gnick: @gnick,
1879
+ pwd: Dir.pwd,
1880
+ config: method(:config),
1881
+ rsh: self
1882
+ }
1883
+ plugin_instance = plugin_class.new(rsh_context)
1884
+ @plugins << { name: plugin_name, instance: plugin_instance, class: class_name }
1885
+
1886
+ # Load plugin completions
1887
+ if plugin_instance.respond_to?(:add_completions)
1888
+ completions = plugin_instance.add_completions
1889
+ @cmd_completions.merge!(completions) if completions.is_a?(Hash)
1890
+ end
1891
+
1892
+ # Load plugin commands
1893
+ if plugin_instance.respond_to?(:add_commands)
1894
+ commands = plugin_instance.add_commands
1895
+ @plugin_commands.merge!(commands) if commands.is_a?(Hash)
1896
+ end
1897
+
1898
+ puts " Loaded plugin: #{plugin_name} (#{class_name})" if ENV['RSH_DEBUG']
1899
+ end
1900
+ rescue => e
1901
+ puts "Warning: Failed to load plugin '#{plugin_name}': #{e.message}" if ENV['RSH_DEBUG']
1902
+ puts " #{e.backtrace.first}" if ENV['RSH_DEBUG']
1903
+ end
1904
+ end
1905
+
1906
+ # Call on_startup for all plugins
1907
+ @plugins.each do |plugin|
1908
+ begin
1909
+ plugin[:instance].on_startup if plugin[:instance].respond_to?(:on_startup)
1910
+ rescue => e
1911
+ puts "Warning: Plugin '#{plugin[:name]}' on_startup failed: #{e.message}" if ENV['RSH_DEBUG']
1912
+ end
1913
+ end
1914
+ end
1915
+ def call_plugin_hook(hook_name, *args) # Call a lifecycle hook for all plugins
1916
+ results = []
1917
+ @plugins.each do |plugin|
1918
+ begin
1919
+ if plugin[:instance].respond_to?(hook_name)
1920
+ result = plugin[:instance].send(hook_name, *args)
1921
+ results << result unless result.nil?
1922
+ end
1923
+ rescue => e
1924
+ puts "Warning: Plugin '#{plugin[:name]}' hook '#{hook_name}' failed: #{e.message}" if ENV['RSH_DEBUG']
1925
+ end
1926
+ end
1927
+ results
1928
+ end
1929
+ def plugins(*args) # Plugin management command
1930
+ if args.empty?
1931
+ # List all plugins
1932
+ if @plugins.empty?
1933
+ puts "\nNo plugins loaded"
1934
+ puts "Place .rb files in #{@plugin_dir}"
1935
+ return
1936
+ end
1937
+
1938
+ puts "\n Loaded Plugins:".c(@c_prompt).b
1939
+ @plugins.each do |plugin|
1940
+ status = @plugin_disabled.include?(plugin[:name]) ? '[disabled]'.c(196) : '[enabled]'.c(@c_path)
1941
+ puts " #{plugin[:name].ljust(20)} #{status} (#{plugin[:class]})"
1942
+ end
1943
+
1944
+ unless @plugin_disabled.empty?
1945
+ puts "\n Disabled Plugins:".c(@c_stamp)
1946
+ @plugin_disabled.each { |name| puts " #{name}" }
1947
+ end
1948
+ puts
1949
+ elsif args[0] == 'reload'
1950
+ # Reload all plugins
1951
+ @plugins = []
1952
+ @plugin_commands = {}
1953
+ load_plugins
1954
+ puts "Plugins reloaded (#{@plugins.length} loaded)"
1955
+ elsif args[0] == 'enable' && args[1]
1956
+ # Enable a plugin
1957
+ plugin_name = args[1]
1958
+ @plugin_disabled.delete(plugin_name)
1959
+ puts "Plugin '#{plugin_name}' enabled. Use :plugins reload to load it"
1960
+ rshrc
1961
+ elsif args[0] == 'disable' && args[1]
1962
+ # Disable a plugin
1963
+ plugin_name = args[1]
1964
+ unless @plugin_disabled.include?(plugin_name)
1965
+ @plugin_disabled << plugin_name
1966
+ end
1967
+ @plugins.reject! { |p| p[:name] == plugin_name }
1968
+ puts "Plugin '#{plugin_name}' disabled"
1969
+ rshrc
1970
+ elsif args[0] == 'info' && args[1]
1971
+ # Show plugin info
1972
+ plugin_name = args[1]
1973
+ plugin = @plugins.find { |p| p[:name] == plugin_name }
1974
+ if plugin
1975
+ puts "\n Plugin: #{plugin_name}".c(@c_prompt).b
1976
+ puts " Class: #{plugin[:class]}"
1977
+ puts " File: #{@plugin_dir}/#{plugin_name}.rb"
1978
+
1979
+ puts "\n Hooks:".c(@c_nick)
1980
+ %i[on_startup on_command_before on_command_after on_prompt].each do |hook|
1981
+ has_hook = plugin[:instance].respond_to?(hook) ? '✓' : '✗'
1982
+ puts " #{has_hook} #{hook}"
1983
+ end
1984
+
1985
+ puts "\n Extensions:".c(@c_nick)
1986
+ puts " ✓ add_completions" if plugin[:instance].respond_to?(:add_completions)
1987
+ puts " ✓ add_commands" if plugin[:instance].respond_to?(:add_commands)
1988
+ puts
1989
+ else
1990
+ puts "Plugin '#{plugin_name}' not found"
1991
+ end
1992
+ else
1993
+ puts "Usage:"
1994
+ puts " :plugins List all plugins"
1995
+ puts " :plugins reload Reload all plugins"
1996
+ puts " :plugins enable NAME Enable a plugin"
1997
+ puts " :plugins disable NAME Disable a plugin"
1998
+ puts " :plugins info NAME Show plugin details"
1999
+ end
2000
+ end
1347
2001
  def validate_command(cmd) # Syntax validation before execution
1348
2002
  return nil if cmd.nil? || cmd.empty?
1349
2003
  warnings = []
@@ -1359,19 +2013,75 @@ def validate_command(cmd) # Syntax validation before execution
1359
2013
  warnings << "WARNING: Force flag without path" if cmd =~ /rm\s+-[rf]+\s*$/
1360
2014
  warnings << "WARNING: Sudo with redirection" if cmd =~ /sudo.*>/
1361
2015
 
1362
- # Check for common typos in popular commands
1363
- if cmd =~ /^(\w+)/
2016
+ # Check for common typos in popular commands (skip for : commands)
2017
+ if cmd =~ /^(\w+)/ && cmd !~ /^:/
1364
2018
  first_cmd = $1
1365
- unless @exe.include?(first_cmd) || @nick.include?(first_cmd) || first_cmd == "cd"
2019
+ # Check if command exists (don't warn for valid commands)
2020
+ is_valid_cmd = @exe.include?(first_cmd) ||
2021
+ @nick.include?(first_cmd) ||
2022
+ first_cmd == "cd" ||
2023
+ (@plugin_commands && @plugin_commands[first_cmd]) ||
2024
+ (@defuns && @defuns.keys.include?(first_cmd))
2025
+
2026
+ unless is_valid_cmd
1366
2027
  suggestions = suggest_command(first_cmd)
1367
2028
  if suggestions && !suggestions.empty?
1368
- warnings << "Command '#{first_cmd}' not found. Did you mean: #{suggestions.join(', ')}?"
2029
+ # Auto-correct if enabled and first suggestion has distance 2
2030
+ if @auto_correct && levenshtein_distance(first_cmd, suggestions[0]) <= 2
2031
+ warnings << "AUTO-CORRECTING: '#{first_cmd}' → '#{suggestions[0]}'"
2032
+ else
2033
+ warnings << "Command '#{first_cmd}' not found. Did you mean: #{suggestions.join(', ')}?"
2034
+ end
1369
2035
  end
1370
2036
  end
1371
2037
  end
1372
2038
 
1373
2039
  warnings.empty? ? nil : warnings
1374
2040
  end
2041
+ def calc(*args) # Inline calculator using Ruby's Math library
2042
+ if args.empty?
2043
+ puts "Usage: calc <expression>"
2044
+ puts "Examples:"
2045
+ puts " calc 2 + 2"
2046
+ puts " calc \"Math.sqrt(16)\""
2047
+ puts " calc \"Math::PI * 2\""
2048
+ return
2049
+ end
2050
+
2051
+ expression = args.join(' ')
2052
+
2053
+ begin
2054
+ # Safe evaluation with Math library
2055
+ result = eval(expression, binding, __FILE__, __LINE__)
2056
+ puts result
2057
+ rescue SyntaxError => e
2058
+ puts "Syntax error in expression: #{e.message}"
2059
+ rescue => e
2060
+ puts "Error evaluating expression: #{e.message}"
2061
+ end
2062
+ end
2063
+ def apply_auto_correct(cmd) # Apply auto-correction to command
2064
+ return cmd unless @auto_correct
2065
+ return cmd if cmd =~ /^:/ # Don't auto-correct colon commands
2066
+ return cmd unless cmd =~ /^(\w+)/
2067
+
2068
+ first_cmd = $1
2069
+
2070
+ # Don't auto-correct if command exists
2071
+ return cmd if @exe.include?(first_cmd)
2072
+ return cmd if @nick.include?(first_cmd)
2073
+ return cmd if first_cmd == "cd"
2074
+ return cmd if @plugin_commands && @plugin_commands[first_cmd]
2075
+ return cmd if @defuns && @defuns.keys.include?(first_cmd) # Check user defuns
2076
+
2077
+ suggestions = suggest_command(first_cmd)
2078
+ if suggestions && !suggestions.empty? && levenshtein_distance(first_cmd, suggestions[0]) <= 2
2079
+ # Auto-correct using first (closest) suggestion
2080
+ cmd.sub(/^#{first_cmd}/, suggestions[0])
2081
+ else
2082
+ cmd
2083
+ end
2084
+ end
1375
2085
  def execute_conditional(cmd_line)
1376
2086
  # Split on && and || while preserving the operators
1377
2087
  parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
@@ -1653,6 +2363,13 @@ def load_rshrc_safe
1653
2363
  @cmd_stats = {} unless @cmd_stats.is_a?(Hash)
1654
2364
  @bookmarks = {} unless @bookmarks.is_a?(Hash)
1655
2365
  @defuns = {} unless @defuns.is_a?(Hash)
2366
+ @history_dedup = 'smart' unless @history_dedup.is_a?(String)
2367
+ @session_autosave = 0 unless @session_autosave.is_a?(Integer)
2368
+ @auto_correct = false unless [@auto_correct].any? { |v| v == true || v == false }
2369
+ @slow_command_threshold = 0 unless @slow_command_threshold.is_a?(Integer)
2370
+ @plugin_disabled = [] unless @plugin_disabled.is_a?(Array)
2371
+ @plugins = [] unless @plugins.is_a?(Array)
2372
+ @plugin_commands = {} unless @plugin_commands.is_a?(Hash)
1656
2373
 
1657
2374
  # Restore defuns from .rshrc
1658
2375
  if @defuns && !@defuns.empty?
@@ -1787,6 +2504,13 @@ def load_defaults
1787
2504
  @defuns ||= {}
1788
2505
  @switch_cache ||= {}
1789
2506
  @switch_cache_time ||= {}
2507
+ @history_dedup ||= 'smart'
2508
+ @session_autosave ||= 0
2509
+ @auto_correct ||= false
2510
+ @slow_command_threshold ||= 0
2511
+ @plugin_disabled ||= []
2512
+ @plugins ||= []
2513
+ @plugin_commands ||= {}
1790
2514
  puts "Loaded with default configuration."
1791
2515
  end
1792
2516
 
@@ -1860,6 +2584,7 @@ begin # Load .rshrc and populate @history
1860
2584
  @c.restore # Max row & col gotten, cursor restored
1861
2585
  hist_clean # Remove duplicates, etc
1862
2586
  @path.map! {|p| p + "/*"} # Set proper format for path search
2587
+ @plugins_loaded = false # Defer plugin loading until first command
1863
2588
  end
1864
2589
 
1865
2590
  # MAIN PART
@@ -1869,17 +2594,58 @@ loop do
1869
2594
  @node = Etc.uname[:nodename] # For use in @prompt
1870
2595
  h = @history; f = @cmd_frequency; s = @cmd_stats; b = @bookmarks; d = @defuns; load_rshrc_safe; @history = h; @cmd_frequency = f; @cmd_stats = s; @bookmarks = b; @defuns = d # reload prompt but preserve runtime data
1871
2596
  @prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
1872
- system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
2597
+ system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
1873
2598
  @history[0] = "" unless @history[0]
1874
2599
  cache_executables # Use cached executable lookup
2600
+ # Load plugins on first command (lazy loading)
2601
+ unless @plugins_loaded
2602
+ load_plugins
2603
+ @plugins_loaded = true
2604
+ end
2605
+ # Append plugin prompt additions (after plugins loaded)
2606
+ plugin_prompts = call_plugin_hook(:on_prompt)
2607
+ @prompt += plugin_prompts.join if plugin_prompts.any?
2608
+ # Auto-save session if enabled and interval elapsed
2609
+ if @session_autosave && @session_autosave > 0
2610
+ current_time = Time.now.to_i
2611
+ if (current_time - @session_last_save) >= @session_autosave
2612
+ save_session('autosave')
2613
+ @session_last_save = current_time
2614
+ end
2615
+ end
1875
2616
  getstr # Main work is here
1876
2617
  @cmd = @history[0]
1877
2618
  @dirs.unshift(Dir.pwd)
1878
2619
  @dirs.pop
1879
2620
  hist_clean # Clean up the history
1880
2621
  @cmd = "ls" if @cmd == "" # Default to ls when no command is given
1881
- if @cmd.match(/^!\d+/)
1882
- hi = @history[@cmd.sub(/^!(\d+)$/, '\1').to_i+1]
2622
+ # Enhanced history commands
2623
+ if @cmd == '!!'
2624
+ # Repeat last command
2625
+ @cmd = @history[1] if @history.length > 1
2626
+ elsif @cmd =~ /^!-(\d+)$/
2627
+ # Repeat nth to last (e.g., !-2 = 2nd to last)
2628
+ index = $1.to_i
2629
+ @cmd = @history[index] if @history.length > index
2630
+ elsif @cmd =~ /^!(\d+):(\d+)$/
2631
+ # Chain commands (e.g., !5:7 = commands 5, 6, 7)
2632
+ start_idx = $1.to_i + 1
2633
+ end_idx = $2.to_i + 1
2634
+ if start_idx < @history.length && end_idx < @history.length && start_idx <= end_idx
2635
+ commands = @history[start_idx..end_idx].compact.reverse
2636
+ # Filter out colon commands (they don't work in shell chains)
2637
+ commands.reject! { |c| c =~ /^:/ }
2638
+ if commands.empty?
2639
+ puts "Cannot chain colon commands (use shell commands only)"
2640
+ @cmd = "ls" # Default to ls
2641
+ else
2642
+ @cmd = commands.join(' && ')
2643
+ puts " Chaining: #{@cmd}".c(@c_stamp)
2644
+ end
2645
+ end
2646
+ elsif @cmd.match(/^!\d+$/)
2647
+ # Original: !5 = command 5
2648
+ hi = @history[@cmd.sub(/^!(\d+)$/, '\1').to_i+1]
1883
2649
  @cmd = hi if hi
1884
2650
  end
1885
2651
  # Move cursor to end of line and print the full command before clearing
@@ -1929,18 +2695,27 @@ loop do
1929
2695
  elsif @cmd == '#' # List previous directories
1930
2696
  dirs
1931
2697
  else # Execute command
1932
- # Check if it's a user-defined Ruby function FIRST (before any expansions)
2698
+ # Check if it's a plugin command FIRST
1933
2699
  cmd_parts = @cmd.split(/\s+/)
1934
- func_name = cmd_parts[0]
1935
- if self.respond_to?(func_name) && singleton_class.instance_methods(false).include?(func_name.to_sym)
2700
+ cmd_name = cmd_parts[0]
2701
+ if @plugin_commands && @plugin_commands[cmd_name]
1936
2702
  begin
1937
2703
  args = cmd_parts[1..]
1938
- puts "DEBUG: Calling #{func_name} with args: #{args}" if ENV['RSH_DEBUG']
1939
- result = self.send(func_name, *args)
2704
+ result = @plugin_commands[cmd_name].call(*args)
2705
+ puts result unless result.nil?
2706
+ rescue => e
2707
+ puts "Error in plugin command '#{cmd_name}': #{e}"
2708
+ end
2709
+ # Then check if it's a user-defined Ruby function (before any expansions)
2710
+ elsif self.respond_to?(cmd_name) && singleton_class.instance_methods(false).include?(cmd_name.to_sym)
2711
+ begin
2712
+ args = cmd_parts[1..]
2713
+ puts "DEBUG: Calling #{cmd_name} with args: #{args}" if ENV['RSH_DEBUG']
2714
+ result = self.send(cmd_name, *args)
1940
2715
  puts "DEBUG: Result: #{result.inspect}" if ENV['RSH_DEBUG']
1941
2716
  puts result unless result.nil?
1942
2717
  rescue => e
1943
- puts "Error calling function '#{func_name}': #{e}"
2718
+ puts "Error calling function '#{cmd_name}': #{e}"
1944
2719
  end
1945
2720
  else
1946
2721
  # Handle conditional execution (&& and ||)
@@ -1976,6 +2751,7 @@ loop do
1976
2751
  elsif @bookmarks && @bookmarks[@cmd]
1977
2752
  bookmark_data = @bookmarks[@cmd]
1978
2753
  bm_dir = bookmark_data.is_a?(Hash) ? bookmark_data[:path] : bookmark_data
2754
+ bm_dir = bm_dir.sub(/^~/, Dir.home) # Expand tilde
1979
2755
  if Dir.exist?(bm_dir)
1980
2756
  Dir.chdir(bm_dir)
1981
2757
  puts "Jumped to bookmark '#{@cmd}' → #{bm_dir}".c(@c_path)
@@ -2001,10 +2777,27 @@ loop do
2001
2777
  end
2002
2778
  else
2003
2779
  begin
2004
- # Validate command before execution
2780
+ pre_cmd
2781
+
2782
+ # Apply auto-correct if enabled (before validation)
2783
+ @cmd = apply_auto_correct(@cmd)
2784
+
2785
+ # Validate command after auto-correction
2005
2786
  warnings = validate_command(@cmd)
2006
2787
  if warnings && !warnings.empty?
2007
- warnings.each { |w| puts "#{w}".c(196) }
2788
+ # Show non-auto-correct warnings
2789
+ warnings.reject { |w| w.start_with?("AUTO-CORRECTING:") }.each { |w| puts "#{w}".c(196) }
2790
+ # Show auto-correct and ask for confirmation
2791
+ auto_correct_warnings = warnings.select { |w| w.start_with?("AUTO-CORRECTING:") }
2792
+ if auto_correct_warnings.any?
2793
+ auto_correct_warnings.each { |w| puts "#{w}".c(214) }
2794
+ print "Accept auto-correction? (Y/n): "
2795
+ response = $stdin.gets.chomp
2796
+ if response.downcase == 'n'
2797
+ puts "Auto-correction cancelled"
2798
+ next
2799
+ end
2800
+ end
2008
2801
  # For critical warnings, ask for confirmation
2009
2802
  if warnings.any? { |w| w.start_with?("WARNING:") }
2010
2803
  print "Continue anyway? (y/N): "
@@ -2016,7 +2809,16 @@ loop do
2016
2809
  end
2017
2810
  end
2018
2811
 
2019
- pre_cmd
2812
+ # Call plugin on_command_before hooks
2813
+ plugin_results = call_plugin_hook(:on_command_before, @cmd)
2814
+ # If any plugin returns false, skip command
2815
+ if plugin_results.include?(false)
2816
+ puts "Command blocked by plugin"
2817
+ next
2818
+ end
2819
+ # If any plugin returns a modified command, use it
2820
+ modified_cmd = plugin_results.find { |r| r.is_a?(String) }
2821
+ @cmd = modified_cmd if modified_cmd
2020
2822
 
2021
2823
  # Track command frequency for intelligent completion
2022
2824
  cmd_base = @cmd.split.first if @cmd && !@cmd.empty?
@@ -2057,6 +2859,14 @@ loop do
2057
2859
  @cmd_stats[cmd_base][:avg_time] = @cmd_stats[cmd_base][:total_time] / @cmd_stats[cmd_base][:count]
2058
2860
  end
2059
2861
 
2862
+ # Slow command alert
2863
+ if @slow_command_threshold > 0 && elapsed > @slow_command_threshold
2864
+ puts "⚠ Command took #{'%.1f' % elapsed}s (threshold: #{@slow_command_threshold}s)".c(214)
2865
+ end
2866
+
2867
+ # Call plugin on_command_after hooks
2868
+ call_plugin_hook(:on_command_after, @cmd, @last_exit)
2869
+
2060
2870
  post_cmd
2061
2871
  rescue StandardError => err
2062
2872
  puts "\nError: #{err}"