ruby-shell 3.1.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 +64 -1
  4. data/bin/rsh +433 -36
  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.1.0" # Quick wins: Multiple sessions, stats export, bookmark features, themes, config, env management
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)
@@ -144,8 +144,16 @@ begin # Initialization
144
144
  @session_autosave = 0 # Auto-save interval (0 = disabled)
145
145
  @session_last_save = Time.now.to_i # Last auto-save timestamp
146
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
147
154
  Dir.mkdir(Dir.home + '/.rsh') unless Dir.exist?(Dir.home + '/.rsh')
148
155
  Dir.mkdir(@session_dir) unless Dir.exist?(@session_dir)
156
+ Dir.mkdir(@plugin_dir) unless Dir.exist?(@plugin_dir)
149
157
  def pre_cmd; end # User-defined function to be run BEFORE command execution
150
158
  def post_cmd; end # User-defined function to be run AFTER command execution
151
159
  end
@@ -153,7 +161,7 @@ end
153
161
  # HELP TEXT
154
162
  @info = <<~INFO
155
163
 
156
- Hello #{@user}, welcome to rsh v3.1 - the Ruby SHell.
164
+ Hello #{@user}, welcome to rsh v3.2 - the Ruby SHell.
157
165
 
158
166
  rsh does not attempt to compete with the grand old shells like bash and zsh.
159
167
  It serves the specific needs and wants of its author. If you like it, then feel free
@@ -179,7 +187,19 @@ end
179
187
  * Syntax validation - Pre-execution warnings for dangerous or malformed commands
180
188
  * Unified command syntax - :nick, :gnick, :bm all work the same way (list/create/delete)
181
189
 
182
- NEW in v3.1:
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:
183
203
  * Multiple named sessions - :save_session "project" and :load_session "project"
184
204
  * Stats export - :stats --csv or :stats --json for data analysis
185
205
  * Session auto-save - Set @session_autosave = 300 in .rshrc for 5-min auto-save
@@ -188,8 +208,6 @@ end
188
208
  * Color themes - :theme solarized|dracula|gruvbox|nord|monokai
189
209
  * Config management - :config shows/sets history_dedup, session_autosave, etc.
190
210
  * Environment management - :env for listing/setting/exporting environment variables
191
- * Bookmark TAB completion - Bookmarks appear in TAB completion list
192
- * List sessions - :list_sessions shows all saved sessions with timestamps
193
211
 
194
212
  Config file (.rshrc) updates on exit (Ctrl-d) or not (Ctrl-e).
195
213
  All colors are themeable in .rshrc (see github link for possibilities).
@@ -602,7 +620,7 @@ def tab(type)
602
620
  :nick :gnick :bm :bookmark :stats :defun :defun?
603
621
  :history :rmhistory :jobs :fg :bg
604
622
  :save_session :load_session :list_sessions :delete_session :rmsession
605
- :config :env :theme
623
+ :config :env :theme :plugins :calc
606
624
  :info :version :help
607
625
  ]
608
626
  search_str = @tabstr[1..-1] || "" # Remove leading :
@@ -887,6 +905,8 @@ def config(*args) # Configure rsh settings
887
905
  puts "\n Current Configuration:".c(@c_prompt).b
888
906
  puts " history_dedup: #{@history_dedup}"
889
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)'}"
890
910
  puts " completion_limit: #{@completion_limit}"
891
911
  puts " completion_fuzzy: #{@completion_fuzzy}"
892
912
  puts " completion_case_sensitive: #{@completion_case_sensitive}"
@@ -907,13 +927,21 @@ def config(*args) # Configure rsh settings
907
927
  @session_autosave = value.to_i
908
928
  puts "Session auto-save set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
909
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
910
938
  when 'completion_limit'
911
939
  @completion_limit = value.to_i
912
940
  puts "Completion limit set to #{value}"
913
941
  rshrc
914
942
  else
915
943
  puts "Unknown setting '#{setting}'"
916
- puts "Available: history_dedup, session_autosave, completion_limit"
944
+ puts "Available: history_dedup, session_autosave, auto_correct, slow_command_threshold, completion_limit"
917
945
  end
918
946
  end
919
947
  def env(*args) # Environment variable management
@@ -1024,6 +1052,12 @@ def rshrc # Write updates to .rshrc
1024
1052
  conf += "@history_dedup = '#{@history_dedup}'\n" if @history_dedup && @history_dedup != 'smart'
1025
1053
  conf.sub!(/^@session_autosave.*(\n|$)/, "")
1026
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?
1027
1061
  # Only write @cmd_completions if user has customized it
1028
1062
  unless conf =~ /^@cmd_completions\s*=/
1029
1063
  # Don't write default completions to avoid cluttering .rshrc
@@ -1097,20 +1131,28 @@ def help
1097
1131
  right_col << ":fg [id] Foreground job"
1098
1132
  right_col << ":bg [id] Resume in bg"
1099
1133
  right_col << ""
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 << ""
1100
1148
  right_col << "v3.0/3.1 FEATURES:".c(@c_prompt).b
1101
- right_col << ":stats [--csv|--json] Analytics"
1102
- right_col << ":bm \"name\" Create bookmark"
1103
- right_col << "name Jump to bookmark"
1104
- right_col << ":bm --stats Bookmark stats"
1105
- right_col << ":bm --export file Export bookmarks"
1106
- right_col << ":bm --import file Import bookmarks"
1107
- right_col << ":save_session [nm] Save session"
1108
- right_col << ":load_session [nm] Load session"
1109
- right_col << ":list_sessions List all sessions"
1110
- right_col << ":rmsession name|* Delete session(s)"
1111
- right_col << ":theme [name] Color presets"
1112
- right_col << ":config [set val] Settings"
1113
- right_col << ":env [VARNAME] Env management"
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"
1114
1156
  right_col << ""
1115
1157
  right_col << "INTEGRATIONS:".c(@c_prompt).b
1116
1158
  right_col << "r Launch rtfm"
@@ -1331,6 +1373,16 @@ def stats(*args) # Show command execution statistics and analytics
1331
1373
  fname = filename || "rsh_stats.csv"
1332
1374
  export_stats_csv(fname)
1333
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
1334
1386
  end
1335
1387
 
1336
1388
  # Display stats (existing code)
@@ -1373,6 +1425,59 @@ def stats(*args) # Show command execution statistics and analytics
1373
1425
  puts "\n Last command exit status: #{@last_exit == 0 ? 'Success'.c(@c_path) : "Failed (#{@last_exit})".c(196)}"
1374
1426
  puts
1375
1427
  end
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
1376
1481
  def export_stats(filename) # Export stats to file (JSON or CSV based on extension)
1377
1482
  if filename.end_with?('.csv')
1378
1483
  export_stats_csv(filename)
@@ -1744,6 +1849,155 @@ def theme(*args) # Apply color scheme presets
1744
1849
  puts "Theme '#{name}' applied"
1745
1850
  puts "Add this to .rshrc to make it permanent: :theme \"#{name}\""
1746
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
1747
2001
  def validate_command(cmd) # Syntax validation before execution
1748
2002
  return nil if cmd.nil? || cmd.empty?
1749
2003
  warnings = []
@@ -1759,19 +2013,75 @@ def validate_command(cmd) # Syntax validation before execution
1759
2013
  warnings << "WARNING: Force flag without path" if cmd =~ /rm\s+-[rf]+\s*$/
1760
2014
  warnings << "WARNING: Sudo with redirection" if cmd =~ /sudo.*>/
1761
2015
 
1762
- # Check for common typos in popular commands
1763
- if cmd =~ /^(\w+)/
2016
+ # Check for common typos in popular commands (skip for : commands)
2017
+ if cmd =~ /^(\w+)/ && cmd !~ /^:/
1764
2018
  first_cmd = $1
1765
- 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
1766
2027
  suggestions = suggest_command(first_cmd)
1767
2028
  if suggestions && !suggestions.empty?
1768
- 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
1769
2035
  end
1770
2036
  end
1771
2037
  end
1772
2038
 
1773
2039
  warnings.empty? ? nil : warnings
1774
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
1775
2085
  def execute_conditional(cmd_line)
1776
2086
  # Split on && and || while preserving the operators
1777
2087
  parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
@@ -2055,6 +2365,11 @@ def load_rshrc_safe
2055
2365
  @defuns = {} unless @defuns.is_a?(Hash)
2056
2366
  @history_dedup = 'smart' unless @history_dedup.is_a?(String)
2057
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)
2058
2373
 
2059
2374
  # Restore defuns from .rshrc
2060
2375
  if @defuns && !@defuns.empty?
@@ -2191,6 +2506,11 @@ def load_defaults
2191
2506
  @switch_cache_time ||= {}
2192
2507
  @history_dedup ||= 'smart'
2193
2508
  @session_autosave ||= 0
2509
+ @auto_correct ||= false
2510
+ @slow_command_threshold ||= 0
2511
+ @plugin_disabled ||= []
2512
+ @plugins ||= []
2513
+ @plugin_commands ||= {}
2194
2514
  puts "Loaded with default configuration."
2195
2515
  end
2196
2516
 
@@ -2264,6 +2584,7 @@ begin # Load .rshrc and populate @history
2264
2584
  @c.restore # Max row & col gotten, cursor restored
2265
2585
  hist_clean # Remove duplicates, etc
2266
2586
  @path.map! {|p| p + "/*"} # Set proper format for path search
2587
+ @plugins_loaded = false # Defer plugin loading until first command
2267
2588
  end
2268
2589
 
2269
2590
  # MAIN PART
@@ -2273,9 +2594,17 @@ loop do
2273
2594
  @node = Etc.uname[:nodename] # For use in @prompt
2274
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
2275
2596
  @prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
2276
- 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
2277
2598
  @history[0] = "" unless @history[0]
2278
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?
2279
2608
  # Auto-save session if enabled and interval elapsed
2280
2609
  if @session_autosave && @session_autosave > 0
2281
2610
  current_time = Time.now.to_i
@@ -2290,8 +2619,33 @@ loop do
2290
2619
  @dirs.pop
2291
2620
  hist_clean # Clean up the history
2292
2621
  @cmd = "ls" if @cmd == "" # Default to ls when no command is given
2293
- if @cmd.match(/^!\d+/)
2294
- 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]
2295
2649
  @cmd = hi if hi
2296
2650
  end
2297
2651
  # Move cursor to end of line and print the full command before clearing
@@ -2341,18 +2695,27 @@ loop do
2341
2695
  elsif @cmd == '#' # List previous directories
2342
2696
  dirs
2343
2697
  else # Execute command
2344
- # Check if it's a user-defined Ruby function FIRST (before any expansions)
2698
+ # Check if it's a plugin command FIRST
2345
2699
  cmd_parts = @cmd.split(/\s+/)
2346
- func_name = cmd_parts[0]
2347
- 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]
2348
2702
  begin
2349
2703
  args = cmd_parts[1..]
2350
- puts "DEBUG: Calling #{func_name} with args: #{args}" if ENV['RSH_DEBUG']
2351
- 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)
2352
2715
  puts "DEBUG: Result: #{result.inspect}" if ENV['RSH_DEBUG']
2353
2716
  puts result unless result.nil?
2354
2717
  rescue => e
2355
- puts "Error calling function '#{func_name}': #{e}"
2718
+ puts "Error calling function '#{cmd_name}': #{e}"
2356
2719
  end
2357
2720
  else
2358
2721
  # Handle conditional execution (&& and ||)
@@ -2414,10 +2777,27 @@ loop do
2414
2777
  end
2415
2778
  else
2416
2779
  begin
2417
- # 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
2418
2786
  warnings = validate_command(@cmd)
2419
2787
  if warnings && !warnings.empty?
2420
- 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
2421
2801
  # For critical warnings, ask for confirmation
2422
2802
  if warnings.any? { |w| w.start_with?("WARNING:") }
2423
2803
  print "Continue anyway? (y/N): "
@@ -2429,7 +2809,16 @@ loop do
2429
2809
  end
2430
2810
  end
2431
2811
 
2432
- 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
2433
2822
 
2434
2823
  # Track command frequency for intelligent completion
2435
2824
  cmd_base = @cmd.split.first if @cmd && !@cmd.empty?
@@ -2470,6 +2859,14 @@ loop do
2470
2859
  @cmd_stats[cmd_base][:avg_time] = @cmd_stats[cmd_base][:total_time] / @cmd_stats[cmd_base][:count]
2471
2860
  end
2472
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
+
2473
2870
  post_cmd
2474
2871
  rescue StandardError => err
2475
2872
  puts "\nError: #{err}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -12,9 +12,9 @@ date: 2025-10-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'A shell written in Ruby with extensive tab completions, aliases/nicks,
14
14
  history, syntax highlighting, theming, auto-cd, auto-opening files and more. UPDATE
15
- v3.1.0: Multiple named sessions, stats export (CSV/JSON), session auto-save, bookmark
16
- import/export, bookmark statistics, 6 color themes, config management, environment
17
- variable tools, bookmark TAB completion, and much more!'
15
+ v3.2.0: PLUGIN SYSTEM - Extensible architecture with lifecycle hooks (on_startup,
16
+ on_command_before/after, on_prompt), extension points (add_completions, add_commands),
17
+ plugin management, and 3 example plugins included. Plus colon command theming!'
18
18
  email: g@isene.com
19
19
  executables:
20
20
  - rsh
@@ -22,6 +22,7 @@ extensions: []
22
22
  extra_rdoc_files: []
23
23
  files:
24
24
  - ".rshrc"
25
+ - PLUGIN_GUIDE.md
25
26
  - README.md
26
27
  - bin/rsh
27
28
  homepage: https://isene.com/